From 12f9f33c5b77775f3208121167325c7ca879829f Mon Sep 17 00:00:00 2001 From: vgoat21 <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:40:51 +0000 Subject: [PATCH 1/4] chore: bump revengai to 4.72.2 --- pyproject.toml | 2 +- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 875799e..2a6db08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "requests>=2.32", "loguru>=0.7", "pydantic", - "revengai>=4.20.0", + "revengai>=4.72.2", "libbs>=2.16.5", ] diff --git a/uv.lock b/uv.lock index daa5301..752c1d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -820,7 +820,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7" }, { name = "pydantic" }, { name = "requests", specifier = ">=2.32" }, - { name = "revengai", specifier = ">=4.20.0" }, + { name = "revengai", specifier = ">=4.72.2" }, ] [package.metadata.requires-dev] @@ -855,7 +855,7 @@ wheels = [ [[package]] name = "revengai" -version = "4.20.0" +version = "4.72.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lazy-imports" }, @@ -864,9 +864,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/29/9617fe378a9d191d84c62d1f4ad96411dc55ad516c5470b5d95ef27dc5c3/revengai-4.20.0.tar.gz", hash = "sha256:84886a55baf878c7a31f38eeb7199e8b29fa10077378c33c30bc84d64aa646c3", size = 426999, upload-time = "2026-09-02T14:37:02.389Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/0c/f6841e1f8450c57350e081ffdfc3d65b432ba2430bc3254abdb4ba258722/revengai-4.72.2.tar.gz", hash = "sha256:e49fba4fc2ee664e8bc556fcabedd6a3e9c8be9f33ced430f2916e50356811d4", size = 561742, upload-time = "2026-10-12T07:43:42.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/ef/5731372d613239278216f5c152060cfb9091cdc0e5ddebd6d2ee5cf9475d/revengai-4.20.0-py3-none-any.whl", hash = "sha256:a6c362fb691748e1fa8b82a82d232c43a703c4112808a8ab2a98b93b3d24898f", size = 1098020, upload-time = "2026-09-02T14:37:01.028Z" }, + { url = "https://files.pythonhosted.org/packages/47/25/d5e9872cb3e2747dc265c8dca1219128edc9b438013e03efc62c2ac41b7d/revengai-4.72.2-py3-none-any.whl", hash = "sha256:4edd14ada08a185d124f303e926618a8192c8e519cd62d06bb7916eb1dd1afb2", size = 1496400, upload-time = "2026-10-12T07:43:41.044Z" }, ] [[package]] From d725be29beee8727ade3cb5b26d0da4453540feb Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:08:09 +0100 Subject: [PATCH 2/4] fix: build the vendor tree from uv.lock and raise the libbs floor The vendor tree under reai_toolkit/vendor is what the plugin actually imports: both reai_toolkit_entry.py and tests/conftest.py prepend it to sys.path ahead of site-packages. build_vendor.sh resolved it with `uv pip install .`, which ignores uv.lock and re-resolves from the pyproject floors, so the shipped tree drifted away from the lockfile -- revengai 4.20.0 vendored against 4.72.2 locked, libbs 3.8.1 vendored against 2.16.5 locked. Every test run to date resolved the vendored 4.20.0, so the SDK bump was never actually exercised. Export the locked runtime set and install that instead, and add scripts/check_vendor.py to assert the tree matches uv.lock (environment markers evaluated, so Windows-only pins are not reported missing on macOS). build_vendor.sh now runs the check on every rebuild. Raise the libbs floor to 3.8.1. The lock sat on the 2.16.5 floor, whose decompilers/ida/compat.py does a bare `import ida` on the headless path and fails to import under IDA 9.3 idalib; 3.8.1 is what the drifted tree had been shipping, so this codifies the working version rather than changing it. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 2 +- scripts/build_vendor.sh | 10 ++- scripts/check_vendor.py | 79 +++++++++++++++++++++ uv.lock | 149 +++++++++++++++++++--------------------- 4 files changed, 158 insertions(+), 82 deletions(-) create mode 100644 scripts/check_vendor.py diff --git a/pyproject.toml b/pyproject.toml index 2a6db08..7d63f88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "loguru>=0.7", "pydantic", "revengai>=4.72.2", - "libbs>=2.16.5", + "libbs>=3.8.1", ] [build-system] diff --git a/scripts/build_vendor.sh b/scripts/build_vendor.sh index 56fbe22..cda0d97 100755 --- a/scripts/build_vendor.sh +++ b/scripts/build_vendor.sh @@ -4,20 +4,28 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." VENDOR="reai_toolkit/vendor" +REQ="$(mktemp -t reai-vendor-req.XXXXXX)" +trap 'rm -f "$REQ"' EXIT if ! command -v uv >/dev/null 2>&1; then echo "uv is not on PATH; see https://docs.astral.sh/uv/getting-started/install/" >&2 exit 1 fi +echo "==> Exporting locked runtime dependencies from uv.lock" +uv export --frozen --no-dev --no-emit-project --no-hashes -o "$REQ" >/dev/null + echo "==> Rebuilding vendor tree at $VENDOR" rm -rf "$VENDOR" mkdir -p "$VENDOR" -uv pip install . --target "$VENDOR" --no-cache-dir +uv pip install -r "$REQ" --target "$VENDOR" --no-cache-dir echo "==> Dropping the self-install so the checkout is not shadowed" rm -rf "$VENDOR/reai_toolkit" "$VENDOR"/reai_toolkit-*.dist-info +echo "==> Verifying the vendor tree matches uv.lock" +uv run python scripts/check_vendor.py + echo "==> Vendored (this is what the plugin and the tests import)" for dist in "$VENDOR"/*.dist-info; do [ -d "$dist" ] || continue diff --git a/scripts/check_vendor.py b/scripts/check_vendor.py new file mode 100644 index 0000000..2ca3e0b --- /dev/null +++ b/scripts/check_vendor.py @@ -0,0 +1,79 @@ +import re +import subprocess +import sys +from pathlib import Path + +from packaging.markers import Marker + +ROOT = Path(__file__).resolve().parent.parent +VENDOR = ROOT / "reai_toolkit" / "vendor" +PIN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s;]+)\s*(?:;\s*(.+))?$") + + +def normalise(name): + return re.sub(r"[-_.]+", "-", name).lower() + + +def locked(): + out = subprocess.run( + ["uv", "export", "--frozen", "--no-dev", "--no-emit-project", "--no-hashes"], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + pinned = {} + for line in out.splitlines(): + line = line.strip() + if line.startswith("#") or not line: + continue + match = PIN.match(line) + if not match: + continue + name, version, marker = match.groups() + if marker and not Marker(marker).evaluate(): + continue + pinned[normalise(name)] = version + return pinned + + +def vendored(): + found = {} + for dist in VENDOR.glob("*.dist-info"): + name, _, version = dist.name[: -len(".dist-info")].rpartition("-") + if name: + found[normalise(name)] = version + return found + + +def main(): + if not VENDOR.is_dir(): + print(f"vendor tree missing at {VENDOR}; run scripts/build_vendor.sh", file=sys.stderr) + return 1 + + want = locked() + have = vendored() + problems = [] + + for name, version in sorted(want.items()): + if name not in have: + problems.append(f" missing: {name}=={version}") + elif have[name] != version: + problems.append(f" mismatch: {name} vendored {have[name]}, lock wants {version}") + + for name, version in sorted(have.items()): + if name not in want: + problems.append(f" unexpected: {name}=={version} is vendored but not in uv.lock") + + if problems: + print("vendor tree does not match uv.lock:", file=sys.stderr) + print("\n".join(problems), file=sys.stderr) + print("\nrun scripts/build_vendor.sh to rebuild", file=sys.stderr) + return 1 + + print(f"vendor tree matches uv.lock ({len(want)} packages)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 752c1d5..38feec7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 5 +revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12'", @@ -152,6 +152,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, ] +[[package]] +name = "declib" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "platformdirs" }, + { name = "ply" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "pycparser" }, + { name = "pyghidra" }, + { name = "setuptools" }, + { name = "toml" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/06/f0494429f882ee4d5c4ca94c21e11abc4cee79fcd4939f4133887d919528/declib-4.5.0.tar.gz", hash = "sha256:333dd38992639eb7eb498fe18c26d88eaaa76b20ace504c59f92e2eb0bcb5a07", size = 280134, upload-time = "2026-08-04T01:38:58.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/fe/1a435da0d3b7f6dd64b1bc1851b57064fc54f8c551506bbc2edbf8e29e10/declib-4.5.0-py3-none-any.whl", hash = "sha256:f320a6daca1c76b68d6d1fe60932afb9817c95f167e1976a4fe2ceab9debee2f", size = 253331, upload-time = "2026-08-04T01:38:57.307Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -173,18 +196,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] -[[package]] -name = "ghidra-bridge" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jfx-bridge" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a4/b4/adc97c3222fd4d48903b8155e17f9c6fadc94b5d18daca69d9c98fa6ac21/ghidra_bridge-1.0.0.tar.gz", hash = "sha256:3a7bdf9c8c1dc78acd3e8cfe9649d0e9554e47e147435730104efee634b01c9d", size = 22384, upload-time = "2023-01-30T03:52:22.462Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/27/68da14f96a9df404acd800969fb12058274f717668ca876be2a2b4d98387/ghidra_bridge-1.0.0-py2.py3-none-any.whl", hash = "sha256:d73d363b99ad893b04dcae4455120b7b44f83899fde14194fcf476cd1656141e", size = 20848, upload-time = "2023-01-30T03:52:20.741Z" }, -] - [[package]] name = "idapro" version = "0.0.6" @@ -212,47 +223,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jfx-bridge" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/13/796445dd00e517bc9551261cb86e9b0d8cff873b37f92421a3e0badc61bc/jfx_bridge-1.0.0.tar.gz", hash = "sha256:e291b51f510bd2587619434f94c89f0c805936c492924c0d905e9222fa290729", size = 42624, upload-time = "2023-01-30T03:42:09.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/8b/6b054a1dd51f71da6324554eceb30e0bd4c8254c1d7c2744b9791fb0547c/jfx_bridge-1.0.0-py2.py3-none-any.whl", hash = "sha256:c7beda4776e219fdca60c42cbce31a97df392c164474c9b5420ed033f346ad30", size = 37606, upload-time = "2023-01-30T03:42:08.372Z" }, -] - [[package]] name = "jpype1" -version = "1.6.0" +version = "1.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/49/6090a131d84b22c6aae13b1853092028b060fd17da1af87c0e42ad69d50f/jpype1-1.6.0.tar.gz", hash = "sha256:2d46b2a14f8f0e6f17d8aa22b4fc3a64b2790851ebf1409ad79a37c698fd6e9a", size = 1057888, upload-time = "2025-07-07T14:04:11.244Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/68/47fa634cbd0418cbca86355e9421425f5892ee994f7338106327e49f9117/jpype1-1.5.2.tar.gz", hash = "sha256:74a42eccf21d30394c1832aec3985a14965fa5320da087b65029d172c0cec43b", size = 1011421, upload-time = "2025-01-27T10:33:16.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/95/524c67e2aafa301d6ffd21747847c5e9c9785ff8b39d90275ead2877dd0c/jpype1-1.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:533cf7ced58a4b74272f3d1e962fe33f305184d2fcb3856ca79867d5b6f0fb8b", size = 583402, upload-time = "2025-07-07T13:50:13.487Z" }, - { url = "https://files.pythonhosted.org/packages/13/61/88288a341292d3b76967587e59b674b5445678dcf60e8c921507bd70baa6/jpype1-1.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:597063698c074e5bf34e696626423a528cdf099989e22a837e889a0d671fd9e4", size = 468810, upload-time = "2025-07-07T13:50:16.967Z" }, - { url = "https://files.pythonhosted.org/packages/08/ed/18a16c6ae0ac5978fe93ad5c265719a311a613ba3dd4d96bd89e93fd2444/jpype1-1.6.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2f0cd698d160ba825952393b4d87911e7eedcbf5af381bb6438126de863f66b6", size = 512880, upload-time = "2025-07-07T13:50:20.451Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/9854e41f4b301a52210254d24d5d62792e0700527f7940445d763d0d64c6/jpype1-1.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fcabb8cce3be16528bd26e4b73e41d7b8c778111f14de52c33c25e2a9d4c9a9f", size = 496509, upload-time = "2025-07-07T13:50:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/74/85/2975be78a88678c9048d39141f39f1ced1b55e937e7854bfd824282ee176/jpype1-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:f02c419d6cdd45ed5576d95f0ab4732371c760ba4b01ea9bd646b98b3c21a16f", size = 356591, upload-time = "2025-07-07T13:50:26.994Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ff/688b8ecbce3c4fa8ee34bc364d447f372d2dcc9597742e7331a328b07f20/jpype1-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40b523e11b22398a8ddb777f1e6e8d55108a631311f35b48b0ed0f4c9198d025", size = 583485, upload-time = "2025-07-07T13:50:29.366Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2a/997c0381028998522ef9103a1730be40eee2c8eb5ca244a295f9d5c6a6e2/jpype1-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ae5927e441894d41b65e08390a0ea6a6512fe222c07aa33fbab623512092fdbd", size = 468977, upload-time = "2025-07-07T13:50:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f9/e3b0a467b3ac2c4eeac5ca57764bf49a859b10f08f43e5179e1a283d2a9b/jpype1-1.6.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9d85c2077172a32dd8ffde8711cb57c5bc351378ceb44dd8c3bdd80f27fa8caf", size = 512946, upload-time = "2025-07-07T13:50:33.452Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b4/1167be1572704fc365f31038a862afae26dda5006c7f4178f44c27779e37/jpype1-1.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5a02cbba4022a0aa47ee617bc12349457988c653491484a988dc8f4e6269dfc", size = 496609, upload-time = "2025-07-07T13:50:35.631Z" }, - { url = "https://files.pythonhosted.org/packages/25/a9/3ed33c24c86d234cb6fd5c6701b64f0a39ed4fd63fd5bd45ad8ce6ac72d9/jpype1-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:966daa892e6cd9ec3b2b92fd7a6d572687b29021e6d28fb73995a62ddd0a11eb", size = 356597, upload-time = "2025-07-07T13:50:37.54Z" }, - { url = "https://files.pythonhosted.org/packages/38/88/2156a27251492169f947850293d9870d49d784f1355fad7e920095636721/jpype1-1.6.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5454322887505097c46f41521becf31cda2303f54e35cb42e20f1a180055a558", size = 582187, upload-time = "2025-07-07T13:50:39.034Z" }, - { url = "https://files.pythonhosted.org/packages/6b/5d/51bde8695cc3eabcc465779f6dc23b149194bbcdcb8d221a9572422053f4/jpype1-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cf646459023a0dd71b3cc3aeeb977225a93f94c97839bb4e6146977b166c7caf", size = 468341, upload-time = "2025-07-07T13:50:40.739Z" }, - { url = "https://files.pythonhosted.org/packages/7a/a1/50cdc2bf9e51c0828faabec6fad50d61aada5a57d0bc435f4db0c16eb2ca/jpype1-1.6.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7a98b34bc68a117881382dffa9b02d5e1d0d94325f85d7f564c33cc9e9d2916b", size = 511724, upload-time = "2025-07-07T13:50:42.479Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a4/6038228e44cbcdb8f96ff2518cd6c577730f31fb62e9f46de7efdfabc2de/jpype1-1.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56d7b662ce353c7f876e9ff3d4e775918348340795801e1f00c3b6241006264", size = 495897, upload-time = "2025-07-07T13:50:44.018Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e4/af69d43779da0e49d37d7fd8a4a01bede03f6a3bab450eab1c10cf48e20e/jpype1-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf41134bc59c28de2721614ed00a34f76f69f2b525583a5c42fee5c7bae05d40", size = 355702, upload-time = "2025-07-07T13:50:45.368Z" }, - { url = "https://files.pythonhosted.org/packages/3f/1b/7167cfb702cfbcb17b0973150608f9cb92c3f409ebaaff655f7389617766/jpype1-1.6.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ce1da9ff01010a4529de194aecc053bfc8ab25440ea6b63579ef8c8d381da21d", size = 582384, upload-time = "2025-07-07T13:50:47.418Z" }, - { url = "https://files.pythonhosted.org/packages/c0/48/8ae425d8842bc76eb51052bbea5941dac230c8f6b58614ef2b7cb2da1a74/jpype1-1.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a387430cd4f3f68761013dca7f71b79d8d64b47dfd56eedcbd44c894d98d7f2", size = 468237, upload-time = "2025-07-07T13:50:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/ef/2f/dbdc15aa99404dda0315a9883add7422c9ac7acce28ce24b66c89052c2fb/jpype1-1.6.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6c9d99f41a5d05a8a2b6aeda796f646904c1c2b68aed0cc9a3bbc165483d9e1e", size = 511905, upload-time = "2025-07-07T13:50:50.87Z" }, - { url = "https://files.pythonhosted.org/packages/00/0d/50fcdd6d3bb42a87cbda8b04fc72283a9c6d955ada0d011190ce31e0e027/jpype1-1.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55ba7257988a69bee84f7cd1444131725d5447999149fdafdba25bf46e4b1a3f", size = 496001, upload-time = "2025-07-07T13:50:52.711Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b8/25214a9ebecbd31447b685500dca5fe2ac070df38358d6823f591a8ce52e/jpype1-1.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:ef8dd72442d91cde7e5eefef24ab71323cdd4137283a59f79d265a6620f7d0ab", size = 355727, upload-time = "2025-07-07T13:51:00.402Z" }, - { url = "https://files.pythonhosted.org/packages/29/a0/b2347572998020de8ce3ee0a285a1b84b052b522e8d452ee9ad330570918/jpype1-1.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc0c1c8b989b9857f87648980a746084b323858b0450225eab3642bb674645d6", size = 471150, upload-time = "2025-07-07T13:50:54.254Z" }, - { url = "https://files.pythonhosted.org/packages/7c/62/5e0f64a185f37303d0dd07e7b7a927cd5237fdc3c8176add949ed8cafaa2/jpype1-1.6.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:cbbba16381ccd23530ad4ef09f7b2b1b12bd7fd0f6ca9fe4c7b4e3da8fe4cf63", size = 514015, upload-time = "2025-07-07T13:50:56.299Z" }, - { url = "https://files.pythonhosted.org/packages/72/68/84050ed2679e2d2ee2030a60d3e5a59a29d0533cd03fd8d9891e36592b0f/jpype1-1.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a3814e4f65d67e36bdb03b8851d5ece8d7a408aa3a24251ea0609bb8fba77dd", size = 497229, upload-time = "2025-07-07T13:50:57.842Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/b2efcad1ea5a541f125218e4eb1529ebb8ca18941264c879f3e89a36dc35/jpype1-1.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7b2da98c142812ca40a18a735b33e47c6511b03debf1e979630f4cf473b68a87", size = 584511, upload-time = "2025-01-27T10:32:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c6/63538d160c17e837f62d29ba4163bc444cef08c29cd3f3b8090691c1869c/jpype1-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcfc5c1d45d6b108800d172ea817bda585db7f1646d6a98d14da9aca66e0eb44", size = 467488, upload-time = "2025-01-27T10:32:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/91/69/655d73a64ae6731706eab73d3f63fd2d48e36704da09710fbc0155c14402/jpype1-1.5.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:156f469a976cc61f695a2455db6de7334774388e7d53c3da8a0a23d9c062d1b2", size = 510064, upload-time = "2025-01-27T10:32:56.756Z" }, + { url = "https://files.pythonhosted.org/packages/97/0a/cbe03759331c640aa5862f974028122a862b08935a0b11b8fa6f6e46c26b/jpype1-1.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdca93cc74f8db1f604d2ea6adb764dec4dec68528f1ee68308fa3d524095739", size = 494097, upload-time = "2025-01-27T10:32:59.875Z" }, + { url = "https://files.pythonhosted.org/packages/22/18/0a51845ca890ffdc72f4d71a0c2be334b887c5bb6812207efe5ad45afcb3/jpype1-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:924b0a0cf93d3dddb3f79286fbe40f8c901c78ed61216edbe108666234df43e0", size = 356655, upload-time = "2025-01-27T10:33:02.702Z" }, + { url = "https://files.pythonhosted.org/packages/35/a0/638186a75026a02286041e4a0449b1dff799a3914dc1c0716ef9b9367b73/jpype1-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c9f6ab8dd284c16e2617a697d54c3d0304b08020a37386ed96103a129391a2d9", size = 584474, upload-time = "2025-01-27T10:33:04.674Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/95db2eb3c8a7311ee08a2c237cea24828859db6a6cb5e901971d3f5e49da/jpype1-1.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a54a771ee56260f98e5b9a77455084e4a48061967de13dabf628bdba9c8122e0", size = 467508, upload-time = "2025-01-27T10:33:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/84/07/f4ed08bf1f65526d59a07efa86a79a2cc37263fea9a91efeb7dfd2b81bcb/jpype1-1.5.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:05dc6d2759111483a9c808a50e67f556efe494e999585c7d7e7d6d8a8ee58525", size = 510256, upload-time = "2025-01-27T10:33:09.465Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7d/9fdbbc1a574be43f9820735ca8df0caf8b159856201d9b21fd73932342bc/jpype1-1.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b900e154826a076118d074166596f1d817e113e07084bf0c9c43d8064a86ab77", size = 494076, upload-time = "2025-01-27T10:33:11.864Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b9/4dfb38a7f4efb21f71df7344944a8d9a23e30d0503574e455af6ce4f1a56/jpype1-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a0d18d4384b3df2e55282545737dfcf18c604504f1382ad14f880bef960f265", size = 356587, upload-time = "2025-01-27T10:33:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e4/0c27352e8222dcc0e3ce44b298015072d2057d08dd353541c980a31d26c9/jpype1-1.5.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1e1db9ac909ad2ae0e40b04c2aa88cb14250d5245d69715561507681f2b08b2f", size = 583445, upload-time = "2025-01-27T10:34:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4c/e0200a6e3fed5cda79e926c2a8a610676f04948f89d7e38d93c7d4b21be9/jpype1-1.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:994fb7b319b453f77ad4b6aff01e0dd4180ea74a6fe5a031e4e9db92dbe95376", size = 466485, upload-time = "2025-01-27T10:34:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ad/d85926b2f0104ded953fa53ff95fe71c96935cd742fdadb888f1145ffe79/jpype1-1.5.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec2f1009d7221fb3443decfb8f326039febc93578aadedfb3e052dab0afbf5a", size = 509372, upload-time = "2025-01-27T10:34:31.94Z" }, + { url = "https://files.pythonhosted.org/packages/74/f3/1cd4332076ed0421e703412f47f15f43af170809435c57ba3162edc80d4b/jpype1-1.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b1fb2b430a50f081ea0ee24d19232ae0d03dbfe3dd076ec5f8ae42b30a656f", size = 493520, upload-time = "2025-01-27T10:34:35.301Z" }, + { url = "https://files.pythonhosted.org/packages/74/dd/7408d4beae755de6fcd07c76b2f0bacabc0461b43fba83811c1f7c22440e/jpype1-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:c7b1c2d76d211cab60be16505d32a6b3c9fffc51ce79c68e81a3d48e5effff2d", size = 356149, upload-time = "2025-01-27T10:34:38.135Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/b37005bec457b94eaaf637a663073b7c5df70113fd4ae4865f6e386c612f/jpype1-1.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4acb098cb1698b14b6e5c79e275f4c70dcc01b0fb93425f206d0a5e380e43c66", size = 583600, upload-time = "2025-01-27T10:34:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/a3/00a265d424f7d47e0dc547df2320225ce0143fec671faf710def41404b8c/jpype1-1.5.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c08480c7d18125664a12bf0a244b96b49c05105306b65937dbefeb05ab4b2847", size = 466426, upload-time = "2025-01-27T10:34:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cd/890d9ed43d7e1366e151c0ed7046e59f6c1cb7ecfc8ecfefe9e5e79f8420/jpype1-1.5.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6819f231c651ef876ffb23158083ea498ff80b57c46da537148412aa22235a13", size = 509513, upload-time = "2025-01-27T10:34:44.538Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d0/191db2e9ab6ae7029368a488c9d88235966843b185aba7925e54aa0c0013/jpype1-1.5.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42fe8db66ad4e5c66f637f5c4de82fca880ba696104e1f4a7e575885923dead8", size = 493474, upload-time = "2025-01-27T10:34:46.37Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b7/e1787633b41d609320b41d0dd87fe3118598210609e4e3f6cef93cfcef40/jpype1-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2b96365f1302df2fb3c6ad73117d6fe450a55b7550fd7fecadac3cec5bc7117c", size = 356150, upload-time = "2025-01-27T10:34:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c8/76541ffefa6fc4ee7a3a8c7e6d9e376f5ac8980a47ff31a8c330ffbb5dcf/jpype1-1.5.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ea5001f6a7a42be6f5f500dcb20dad5738fef6a7d19c86dbcf482b803f01cab", size = 468412, upload-time = "2025-01-27T10:34:49.155Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/827ca43aaa5ea6b773aa90b405acec22bd152d1284aa40f7c0c02bc1657b/jpype1-1.5.2-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220d33305998ff7a7cbc9c0b5eea54f2691fdc21e60d52ad56276ea13fd5bd4c", size = 511322, upload-time = "2025-01-27T10:34:52.832Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/f1396d7b66f9b6867a279db510e625ba51c8a4b4397f59a6c2b20fb55548/jpype1-1.5.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f1ed152fdd5200067dc0cdcd4683530b87a6be3987b98596cdf5a7d3ac4c679", size = 494714, upload-time = "2025-01-27T10:34:54.785Z" }, ] [[package]] @@ -266,27 +268,14 @@ wheels = [ [[package]] name = "libbs" -version = "2.16.5" +version = "3.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "filelock" }, - { name = "ghidra-bridge" }, - { name = "jfx-bridge" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "platformdirs" }, - { name = "ply" }, - { name = "prompt-toolkit" }, - { name = "psutil" }, - { name = "pycparser" }, - { name = "pyhidra" }, - { name = "setuptools" }, - { name = "toml" }, - { name = "tqdm" }, + { name = "declib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/e2/3652680339a08a9dfb2a11345e546b603f101c1ac1e511bcdab89d3a2a0d/libbs-2.16.5.tar.gz", hash = "sha256:b47b7f0fb6e271ddb177c4b70fee42cd9323a46c904c8015ec54695c470f5a62", size = 132805, upload-time = "2025-10-13T12:43:23.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/c0/35aebcc1ed3e75ddc8c07f7b900580ad3f81946703b3b00457f091af84ec/libbs-3.8.1.tar.gz", hash = "sha256:8605a519bf3a2611a9c59896768fe52d3cb1906e43407fba43e858f2b78df445", size = 3189, upload-time = "2026-06-03T17:12:32.039Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/8a/51f5c1f256829ebdc639260c4c05a79e0dba5d264a88a8666f0ff23b358d/libbs-2.16.5-py3-none-any.whl", hash = "sha256:5046e0e8a2e8bb39fdd336864039e4b06e2f9838392784aa1c3ca968cb581766", size = 141803, upload-time = "2025-10-13T12:43:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/78/69/a1ead5e2459c67274bec8875a08ea1cddf89d29ef1bb55068e0c8bed5e46/libbs-3.8.1-py3-none-any.whl", hash = "sha256:73b6b9651677895b60c1227609cf140adc94eb40727474ec115ff1d85561296a", size = 3331, upload-time = "2026-06-03T17:12:31.121Z" }, ] [[package]] @@ -403,11 +392,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, ] [[package]] @@ -540,25 +529,25 @@ wheels = [ ] [[package]] -name = "pygments" -version = "2.19.2" +name = "pyghidra" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +dependencies = [ + { name = "jpype1" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/eb/842a540cf3a8328a76636de17b0f5b3b11ac75c50a89660260f27021412a/pyghidra-3.1.0.tar.gz", hash = "sha256:2106ac131eb9a4990a79ee84dc2d39a792cf7b2d0de5eaaf1b0e6d7d2d290bb6", size = 53509, upload-time = "2026-05-14T10:00:11.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e9/1d8f9a790be5c6d3f95a8c1428016269c0c049ae70463bc3edbc76f2bb04/pyghidra-3.1.0-py3-none-any.whl", hash = "sha256:62d76e4c42352fc38cb3a9526ba7e25681d21388c16e59a2d17e99aeaf47ac94", size = 51595, upload-time = "2026-05-14T09:59:59.323Z" }, ] [[package]] -name = "pyhidra" -version = "1.3.0" +name = "pygments" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jpype1" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/87/7f/1de83b2299371f013196d044cfc95d48116a4067f4ff9d5197eb509dc9f5/pyhidra-1.3.0.tar.gz", hash = "sha256:1fae93db37000b7b12d8011500d9367a904fafb09476190e519f61b013d440e4", size = 47568, upload-time = "2024-10-04T16:25:19.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/71/42b696a1e5a81f12a27898b1f83f16b5109f3702e884248d75238e4ab92e/pyhidra-1.3.0-py3-none-any.whl", hash = "sha256:768b5199cf94bafe33bfa64c836380c976dad68db937e1feefb5b5e8f8035c73", size = 56999, upload-time = "2024-10-04T16:25:17.754Z" }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] @@ -816,7 +805,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "libbs", specifier = ">=2.16.5" }, + { name = "libbs", specifier = ">=3.8.1" }, { name = "loguru", specifier = ">=0.7" }, { name = "pydantic" }, { name = "requests", specifier = ">=2.32" }, From 6e9f191678d09acb04a343f1fd9471d8d21d5611 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:33:02 +0100 Subject: [PATCH 3/4] fix: guard symbol collection against 4.72.2 validation constraints revengai 4.72.2 tightened the pydantic constraints on two models the plugin constructs by hand: FunctionBoundary.mangled_name StrictStr -> min_length=1, max_length=4096 FunctionBoundary.start_address StrictInt -> ge=0 FunctionBoundary.end_address StrictInt -> ge=0 Symbols.base_address StrictInt -> ge=0 Symbols.function_boundaries List -> max_length=200000 collect_symbols_from_ida fed idc.get_func_name() straight into mangled_name. That returns "" for an unnamed function, which now fails min_length=1 and raises ValidationError out of the whole collection pass -- so upload would break outright on any binary containing one. The boundary list was also unbounded, and a negative image base would trip ge=0. Skip boundaries with no name rather than inventing one, truncate over-long names, clamp the base address, and cap the list, warning when either the skip count or the cap is non-zero so the truncation is not silent. Co-Authored-By: Claude Opus 5 (1M context) --- reai_toolkit/app/core/utils.py | 55 +++++++++++++++++++++++---- tests/unit/core/test_utils.py | 69 +++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/reai_toolkit/app/core/utils.py b/reai_toolkit/app/core/utils.py index 54facec..6fa7ac6 100644 --- a/reai_toolkit/app/core/utils.py +++ b/reai_toolkit/app/core/utils.py @@ -2,6 +2,8 @@ import json from pathlib import Path +from loguru import logger + from libbs.decompilers.ida.compat import execute_read, execute_write import ida_funcs @@ -14,6 +16,10 @@ from revengai.models.symbols import Symbols +MAX_MANGLED_NAME_LENGTH = 4096 +MAX_FUNCTION_BOUNDARIES = 200000 + + def parse_exception(exception: ApiException) -> BaseResponse | None: """ If the exception body is JSON and matches BaseResponse, return it. @@ -67,10 +73,38 @@ def demangle(mangled_name: str, attr: int = idc.INF_SHORT_DN) -> str: return demangled_name if demangled_name else mangled_name +def build_function_boundary( + mangled_name: str | None, start_address: int, end_address: int +) -> FunctionBoundary | None: + if not mangled_name: + return None + + if start_address < 0 or end_address < 0: + return None + + return FunctionBoundary( + mangled_name=mangled_name[:MAX_MANGLED_NAME_LENGTH], + start_address=start_address, + end_address=end_address, + ) + + +def build_symbols(base_address: int, funcs: list[FunctionBoundary]) -> Symbols: + if len(funcs) > MAX_FUNCTION_BOUNDARIES: + logger.warning( + f"RevEng.AI: Truncating function boundaries from {len(funcs)} to " + f"{MAX_FUNCTION_BOUNDARIES}" + ) + funcs = funcs[:MAX_FUNCTION_BOUNDARIES] + + return Symbols(base_address=max(int(base_address), 0), function_boundaries=funcs) + + @execute_read def collect_symbols_from_ida(inclusive_end: bool = False) -> Symbols | None: base: int = idaapi.get_imagebase() or 0 funcs: list[FunctionBoundary] = [] + skipped: int = 0 plt_section: idaapi.segment_t | None = idaapi.get_segm_by_name(".plt") @@ -86,14 +120,19 @@ def collect_symbols_from_ida(inclusive_end: bool = False) -> Symbols | None: end_ea: int = f.end_ea - 1 if inclusive_end else f.end_ea mangled_name: str = idc.get_func_name(start_ea) - funcs.append( - FunctionBoundary( - mangled_name=mangled_name, - start_address=start_ea, - end_address=end_ea, - ) + boundary: FunctionBoundary | None = build_function_boundary( + mangled_name, start_ea, end_ea ) + if boundary is None: + skipped += 1 + continue + + funcs.append(boundary) - symbols = Symbols(base_address=int(base), function_boundaries=funcs) + if skipped: + logger.warning( + f"RevEng.AI: Skipped {skipped} function boundaries with an empty name " + "or a negative address" + ) - return symbols + return build_symbols(base, funcs) diff --git a/tests/unit/core/test_utils.py b/tests/unit/core/test_utils.py index dd5c37e..e18fc10 100644 --- a/tests/unit/core/test_utils.py +++ b/tests/unit/core/test_utils.py @@ -1,8 +1,26 @@ import hashlib +from unittest.mock import MagicMock + +import pytest from revengai import ApiException, BaseResponse -from reai_toolkit.app.core.utils import parse_exception, sha256_file +from reai_toolkit.app.core import utils +from reai_toolkit.app.core.utils import ( + MAX_FUNCTION_BOUNDARIES, + MAX_MANGLED_NAME_LENGTH, + build_function_boundary, + build_symbols, + parse_exception, + sha256_file, +) + + +@pytest.fixture +def fake_logger(monkeypatch): + logger = MagicMock() + monkeypatch.setattr(utils, "logger", logger) + return logger def test_parse_exception_returns_base_response_for_json_body(): @@ -43,3 +61,52 @@ def test_sha256_file_handles_multichunk(tmp_path): f.write_bytes(payload) assert sha256_file(f, chunk_size=1024) == hashlib.sha256(payload).hexdigest() + + +def test_build_function_boundary_skips_empty_mangled_name(): + assert build_function_boundary("", 0x1000, 0x1010) is None + assert build_function_boundary(None, 0x1000, 0x1010) is None + + +def test_build_function_boundary_truncates_overlong_mangled_name(): + boundary = build_function_boundary("_Z" + "a" * 8192, 0x1000, 0x1010) + + assert boundary is not None + assert len(boundary.mangled_name) == MAX_MANGLED_NAME_LENGTH + assert boundary.mangled_name.startswith("_Za") + + +def test_build_function_boundary_skips_negative_addresses(): + assert build_function_boundary("main", -1, 0x10) is None + assert build_function_boundary("main", 0x10, -1) is None + + +def test_build_function_boundary_keeps_valid_entry(): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + assert boundary.mangled_name == "main" + assert boundary.start_address == 0x1000 + assert boundary.end_address == 0x1010 + + +def test_build_symbols_caps_function_boundaries(fake_logger): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + symbols = build_symbols(0x400000, [boundary] * (MAX_FUNCTION_BOUNDARIES + 25)) + + assert len(symbols.function_boundaries) == MAX_FUNCTION_BOUNDARIES + fake_logger.warning.assert_called_once() + + +def test_build_symbols_keeps_list_under_cap(fake_logger): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + symbols = build_symbols(0x400000, [boundary]) + + assert symbols.base_address == 0x400000 + assert len(symbols.function_boundaries) == 1 + fake_logger.warning.assert_not_called() + + +def test_build_symbols_clamps_negative_base_address(): + assert build_symbols(-1, []).base_address == 0 From d00489384e718c09970f03fff8cecb4e8412cf17 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:33:36 +0100 Subject: [PATCH 4/4] refactor: migrate the deprecated v2 endpoints onto v3 revengai 4.72.2 marks 85 operations deprecated, up from 25. Eight of the nine the plugin called now emit a DeprecationWarning at call time and have a v3 successor; this moves them over. Every v2 operation returned a BaseResponseX envelope with a .data attribute. Every v3 operation returns a flat *OutputBody. So each migration is: swap the method, drop the .data unwrap, remap fields. ConfigApi.get_config -> ConfigApi.v3_get_config AnalysesCoreApi.get_analysis_status -> AnalysesCoreApi.v3_get_analysis_operation AnalysesCoreApi.get_analysis_function_map -> FunctionsCoreApi.v3_get_analysis_func_maps AnalysesCoreApi.list_analyses -> AnalysesCoreApi.v3_list_analyses AnalysesCoreApi.upload_file -> BinariesApi.v3_upload_file SearchApi.search_binaries -> BinariesApi.v3_search_binaries SearchApi.search_collections -> CollectionsApi.v3_list_collections FunctionsAIDecompilationApi.upsert_ai_decompilation_rating -> ..._api.v3_upsert_ai_decompilation_rating Notes on the non-obvious ones: SearchApi is now deprecated in its entirety -- it has zero non-deprecated operations. Binary search moves to GET /v3/binaries and collection search to GET /v3/collections; both swap page/page_size for limit/offset. CollectionListItemBody renames six fields off CollectionSearchResult (scope, last_updated_at, created_at, owned_by, tags, size). Analysis status moves to the long-running-operation endpoint GET /v3/operations/analyses/{analysis_id}, whose CreateMetadata.status carries the same platform status string v2 returned, so the Complete/Error state machine is unchanged. v3_upload_file drops packed_password. That parameter was dead plumbing here: declared and threaded between _upload_file_req and upload_user_file but never supplied by any caller. Archive passwords moved to the sandbox side of the platform (SandboxConfig.archive_password), so it is removed rather than reimplemented. Also handle the types_applied AI-decompilation stream event, new in 4.72.2 as the EventTypesApplied oneOf variant. The hand-rolled SSE parser keys off event names and had no branch for it, so those events were dropped silently. AnalysesCoreApi.create_analysis stays on v2: POST /v2/analyses is deprecated but has no v3 successor in the live v4.73.0 spec. It is now the only deprecated SDK call left in the plugin. The remaining /v2 calls -- IAMUsersApi.get_me and the seven ConversationsApi operations -- are not deprecated and have no /v3 paths in the spec. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/dialogs/matching_dialog.py | 8 +- .../services/ai_decomp/ai_decomp_service.py | 8 +- reai_toolkit/app/services/ai_decomp/stream.py | 8 ++ .../analysis_status/analysis_status.py | 20 +++-- .../services/analysis_sync/analysis_sync.py | 17 ++-- .../app/services/auth/auth_service.py | 2 +- .../existing_analyses_service.py | 26 +++--- .../app/services/matching/matching_service.py | 28 +++--- .../app/services/upload/upload_service.py | 30 +++---- tests/unit/ai_decomp/test_sdk_schemas.py | 61 ++++++++++++- tests/unit/ai_decomp/test_service.py | 11 +-- tests/unit/ai_decomp/test_stream.py | 23 +++++ .../unit/analysis_status/test_sdk_schemas.py | 21 +++-- tests/unit/analysis_status/test_service.py | 64 +++++++++++--- tests/unit/analysis_sync/test_sdk_schemas.py | 20 ++--- tests/unit/analysis_sync/test_service.py | 24 +++++ tests/unit/auth/test_endpoint_auth.py | 2 +- tests/unit/auth/test_sdk_schemas.py | 8 +- tests/unit/auth/test_service.py | 10 +-- .../existing_analyses/test_sdk_schemas.py | 32 ++++--- tests/unit/existing_analyses/test_service.py | 37 ++++---- tests/unit/matching/test_matching_service.py | 16 ++-- tests/unit/matching/test_sdk_schemas.py | 4 +- tests/unit/upload/test_sdk_schemas.py | 27 ++++++ tests/unit/upload/test_service.py | 87 ++++++++++++++++--- 25 files changed, 429 insertions(+), 165 deletions(-) create mode 100644 tests/unit/upload/test_sdk_schemas.py diff --git a/reai_toolkit/app/components/dialogs/matching_dialog.py b/reai_toolkit/app/components/dialogs/matching_dialog.py index 1e121d8..34cf5d3 100644 --- a/reai_toolkit/app/components/dialogs/matching_dialog.py +++ b/reai_toolkit/app/components/dialogs/matching_dialog.py @@ -4,7 +4,7 @@ from loguru import logger from revengai.models import ( - BinarySearchResult, + BinarySearchResultBody, CollectionListItemBody, FunctionMatch, MatchedFunction, @@ -178,7 +178,7 @@ def __init__( self._initCollectionsHeader() # ----------------- Binaries state ----------------- - self._selected_binaries: dict[str, BinarySearchResult] = {} + self._selected_binaries: dict[str, BinarySearchResultBody] = {} self._binariesDebounce = QtCore.QTimer(self) self._binariesDebounce.setSingleShot(True) self._binariesDebounce.timeout.connect(self._performBinariesSearch) @@ -557,7 +557,7 @@ def _performBinariesSearch(self): text = getattr(self.ui, "editBinaries", None) query = text.text().strip() if text else "" - response: GenericApiReturn[List[BinarySearchResult]] = ( + response: GenericApiReturn[List[BinarySearchResultBody]] = ( self.matching_service.search_binaries(text_input=query) ) @@ -593,7 +593,7 @@ def _initBinariesHeader(self): else: hdr.setSectionResizeMode(i, QtWidgets.QHeaderView.Stretch) - def _fillBinariesPopup(self, query: str, rows: list[BinarySearchResult]): + def _fillBinariesPopup(self, query: str, rows: list[BinarySearchResultBody]): if not hasattr(self.ui, "binariesPopupView"): return view = self.ui.binariesPopupView diff --git a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py index decd440..f81ceac 100644 --- a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py +++ b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py @@ -19,10 +19,8 @@ from revengai.models.summary_data import SummaryData from revengai.models.task_status import TaskStatus from revengai.models.token import Token -from revengai.models.upsert_ai_decomplation_rating_request import ( - UpsertAiDecomplationRatingRequest, -) from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody +from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -239,9 +237,9 @@ def _run_rate_decomp( ) -> None: try: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - FunctionsAIDecompilationApi(api_client).upsert_ai_decompilation_rating( + FunctionsAIDecompilationApi(api_client).v3_upsert_ai_decompilation_rating( function_id=function_id, - upsert_ai_decomplation_rating_request=UpsertAiDecomplationRatingRequest( + upsert_rating_input_body=UpsertRatingInputBody( rating=rating, reason=None ), ) diff --git a/reai_toolkit/app/services/ai_decomp/stream.py b/reai_toolkit/app/services/ai_decomp/stream.py index 1c650d2..1e106c0 100644 --- a/reai_toolkit/app/services/ai_decomp/stream.py +++ b/reai_toolkit/app/services/ai_decomp/stream.py @@ -15,6 +15,7 @@ EVENT_RENAME_APPLIED = "rename_applied" EVENT_SOURCE_DELTA = "source_delta" EVENT_SOURCE_RESET = "source_reset" +EVENT_TYPES_APPLIED = "types_applied" EVENT_TYPES_SUGGESTED = "types_suggested" EVENT_WARNING = "warning" @@ -41,6 +42,7 @@ class DecompEvent: applied: int = 0 types: int = 0 members: int = 0 + skipped: int = 0 def _as_int(value) -> int: @@ -71,6 +73,7 @@ def normalize_event(event_name: Optional[str], payload: dict) -> Optional[Decomp applied=_as_int(payload.get("applied")), types=_as_int(payload.get("types")), members=_as_int(payload.get("members")), + skipped=_as_int(payload.get("skipped")), ) @@ -110,6 +113,8 @@ class StreamState: names_applied: int = 0 types_suggested: int = 0 members_suggested: int = 0 + types_applied: int = 0 + types_skipped: int = 0 def reset_attempt(self, attempt: int) -> None: self.attempt = attempt @@ -139,6 +144,9 @@ def apply(self, event: DecompEvent) -> None: elif event.type == EVENT_TYPES_SUGGESTED: self.types_suggested = event.types self.members_suggested = event.members + elif event.type == EVENT_TYPES_APPLIED: + self.types_applied = event.types + self.types_skipped = event.skipped elif event.type == EVENT_DECOMP_FINISHED: self.decomp_finished = True elif event.type == EVENT_ATTEMPT_FAILED: diff --git a/reai_toolkit/app/services/analysis_status/analysis_status.py b/reai_toolkit/app/services/analysis_status/analysis_status.py index 0c03c83..fcb1e06 100644 --- a/reai_toolkit/app/services/analysis_status/analysis_status.py +++ b/reai_toolkit/app/services/analysis_status/analysis_status.py @@ -5,10 +5,10 @@ from loguru import logger from revengai import ( AnalysesCoreApi, - BaseResponseStatus, Configuration, + CreateMetadata, GetAnalysisLogsOutputBody, - StatusOutput, + OperationCreateMetadataCreateResult, ) from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -37,7 +37,7 @@ def start_polling(self, analysis_id: int, thread_callback: Callable[..., Any]) - args=(analysis_id,), ) - def _api_get_status(self, analysis_id: int) -> StatusOutput | None: + def _api_get_status(self, analysis_id: int) -> CreateMetadata | None: """ Calls the API to get the analysis status. Returns GenericApiReturn with status string on success. @@ -45,12 +45,14 @@ def _api_get_status(self, analysis_id: int) -> StatusOutput | None: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: analyses_client = AnalysesCoreApi(api_client) - analysis_status: BaseResponseStatus = analyses_client.get_analysis_status(analysis_id=analysis_id) - status: StatusOutput | None = analysis_status.data - if status: - self.netstore_service.put_analysis_status(status.analysis_status) + operation: OperationCreateMetadataCreateResult = ( + analyses_client.v3_get_analysis_operation(analysis_id=analysis_id) + ) + metadata: CreateMetadata | None = operation.metadata + if metadata: + self.netstore_service.put_analysis_status(metadata.status) - return analysis_status.data + return metadata def _api_get_logs(self, analysis_id: int) -> GetAnalysisLogsOutputBody | None: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: @@ -83,7 +85,7 @@ def _poll_analysis_status(self, stop_event: threading.Event, analysis_id: int) - for entry in (logs.entries or []): logger.debug(f"RevEng.AI Remote Analysis - {entry.text}") - if get_status_response.data and (status := get_status_response.data.analysis_status): + if get_status_response.data and (status := get_status_response.data.status): logger.info(f"RevEng.AI: Status - {status}") if status == "Complete": self.call_callback( diff --git a/reai_toolkit/app/services/analysis_sync/analysis_sync.py b/reai_toolkit/app/services/analysis_sync/analysis_sync.py index ef50857..82bdfb4 100644 --- a/reai_toolkit/app/services/analysis_sync/analysis_sync.py +++ b/reai_toolkit/app/services/analysis_sync/analysis_sync.py @@ -12,8 +12,9 @@ from revengai import ( AnalysesCoreApi, AnalysisBasicInfoOutputBody, - BaseResponseAnalysisFunctionMapping, Configuration, + FunctionsCoreApi, + GetFunctionMapsOutputBody, ) from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -59,7 +60,7 @@ def start_syncing(self, func_map: FunctionMapping, callback: Callable[[GenericAp args=(func_map, callback), ) - def get_function_matches(self, callback: Callable[[FunctionMapping], None]) -> None: + def get_function_matches(self, callback: Callable[[GetFunctionMapsOutputBody], None]) -> None: analysis_id: int | None = self.netstore_service.get_analysis_id() if analysis_id is None: return @@ -72,7 +73,7 @@ def get_function_matches(self, callback: Callable[[FunctionMapping], None]) -> N logger.error("failed to retrieve model id") return - response: GenericApiReturn[FunctionMapping] = self.api_request_returning( + response: GenericApiReturn[GetFunctionMapsOutputBody] = self.api_request_returning( fn=lambda: self._fetch_function_map(analysis_id=analysis_id) ) @@ -118,17 +119,15 @@ def _get_current_base_address(self) -> int: def _rebase_program(self, base_address_delta: int) -> None: idaapi.rebase_program(base_address_delta, idaapi.MSF_FIXONCE) - def _fetch_function_map(self, analysis_id: int) -> FunctionMapping: + def _fetch_function_map(self, analysis_id: int) -> GetFunctionMapsOutputBody: """ Fetches the function map for the given analysis ID. """ with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - analyses_client = AnalysesCoreApi(api_client) + functions_client = FunctionsCoreApi(api_client) - function_map: BaseResponseAnalysisFunctionMapping = analyses_client.get_analysis_function_map(analysis_id=analysis_id) - if function_map.data: - func_map: FunctionMapping = function_map.data.function_maps - self.netstore_service.put_function_mapping(func_map) + func_map: GetFunctionMapsOutputBody = functions_client.v3_get_analysis_func_maps(analysis_id=analysis_id) + self.netstore_service.put_function_mapping(func_map) return func_map diff --git a/reai_toolkit/app/services/auth/auth_service.py b/reai_toolkit/app/services/auth/auth_service.py index 071b5ab..22039ee 100644 --- a/reai_toolkit/app/services/auth/auth_service.py +++ b/reai_toolkit/app/services/auth/auth_service.py @@ -85,7 +85,7 @@ def verify(self) -> Tuple[bool, str]: if hasattr(self.sdk_config, "user_agent"): api_client.user_agent = self.sdk_config.user_agent try: - ConfigApi(api_client).get_config() + ConfigApi(api_client).v3_get_config() logger.info("RevEng.AI: User authenticated successfully.") self._is_authed = True self.get_me(force_refresh=True) diff --git a/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py b/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py index 0d38132..cd8fa63 100644 --- a/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py +++ b/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py @@ -2,13 +2,7 @@ from typing import List, Optional from revengai import AnalysesCoreApi, Configuration -from revengai.models import ( - AnalysisRecord, - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) +from revengai.models import AnalysisRecordBody from reai_toolkit.app.core.netstore_service import SimpleNetStore from reai_toolkit.app.core.shared_schema import GenericApiReturn @@ -22,28 +16,28 @@ class ExistingAnalysesService(IThreadService): def __init__(self, netstore_service: SimpleNetStore, sdk_config: Configuration): super().__init__(netstore_service=netstore_service, sdk_config=sdk_config) - def _fetch_analysis_records(self, sha256_hash: str) -> List[AnalysisRecord]: + def _fetch_analysis_records(self, sha256_hash: str) -> List[AnalysisRecordBody]: """ Helper method to fetch analysis records by SHA256 hash. - Returns a list of AnalysisRecord. + Returns a list of AnalysisRecordBody. """ with self.yield_api_client(sdk_config=self.sdk_config) as api_client: analyses_client = AnalysesCoreApi(api_client) - result = analyses_client.list_analyses( - workspace=[Workspace.PERSONAL], - status=[StatusInput.COMPLETE], + result = analyses_client.v3_list_analyses( + analysis_scope=["PRIVATE"], + status=["Complete"], sha256_hash=sha256_hash, - order_by=AppApiRestV2AnalysesEnumsOrderBy.CREATED, - order=Order.DESC, + order_by="created", + order="DESC", ) - return result.data.results + return result.results or [] def fetch_analyses_same_hash( self, file_path: str - ) -> GenericApiReturn[List[AnalysisRecord]]: + ) -> GenericApiReturn[List[AnalysisRecordBody]]: """ Fetches analyses with the same hash as the given file. Returns a list of analysis summaries. diff --git a/reai_toolkit/app/services/matching/matching_service.py b/reai_toolkit/app/services/matching/matching_service.py index e04fd9c..7d065ae 100644 --- a/reai_toolkit/app/services/matching/matching_service.py +++ b/reai_toolkit/app/services/matching/matching_service.py @@ -9,7 +9,8 @@ from loguru import logger from revengai import ( - BinarySearchResult, + BinariesApi, + BinarySearchResultBody, CollectionListItemBody, CollectionsApi, Configuration, @@ -20,7 +21,6 @@ GetMatchesStatusOutputBody, MatchFilters, ProgressMessage, - SearchApi, StartMatchingForFunctionsInputBody, TaskStatus, ) @@ -84,42 +84,42 @@ def search_collections( ) return response - def _search_binaries(self, text_input: str) -> List[BinarySearchResult]: + def _search_binaries(self, text_input: str) -> List[BinarySearchResultBody]: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - search_client = SearchApi(api_client) + binaries_client = BinariesApi(api_client) model_name: str | None = self.netstore_service.get_model_name() current_binary_id: int | None = self.netstore_service.get_binary_id() - return_list: List[BinarySearchResult] = [] + return_list: List[BinarySearchResultBody] = [] - response = search_client.search_binaries( + response = binaries_client.v3_search_binaries( partial_name=text_input, partial_sha256=None, - page_size=5, - page=1, + limit=5, + offset=0, model_name=model_name, exclude_binary_id=current_binary_id, ) - return_list.extend(response.data.results) + return_list.extend(response.results or []) - response = search_client.search_binaries( + response = binaries_client.v3_search_binaries( partial_name=None, partial_sha256=text_input, - page_size=5, - page=1, + limit=5, + offset=0, model_name=model_name, exclude_binary_id=current_binary_id, ) - return_list.extend(response.data.results) + return_list.extend(response.results or []) return return_list def search_binaries( self, text_input: str - ) -> GenericApiReturn[List[BinarySearchResult]]: + ) -> GenericApiReturn[List[BinarySearchResultBody]]: response = self.api_request_returning(lambda: self._search_binaries(text_input)) # If the search fails, return an empty list instead of an error if not response.success: diff --git a/reai_toolkit/app/services/upload/upload_service.py b/reai_toolkit/app/services/upload/upload_service.py index 1c06f35..205c092 100644 --- a/reai_toolkit/app/services/upload/upload_service.py +++ b/reai_toolkit/app/services/upload/upload_service.py @@ -4,7 +4,8 @@ from typing import Optional, Tuple from loguru import logger -from revengai import AnalysesCoreApi, BaseResponseConfigResponse, Configuration, Symbols +from revengai import AnalysesCoreApi, Configuration, GetConfigOutputBody, Symbols +from revengai.api.binaries_api import BinariesApi from revengai.api.config_api import ConfigApi from revengai.models.analysis_config import AnalysisConfig @@ -13,7 +14,7 @@ from revengai.models.analysis_scope import AnalysisScope from revengai.models.tag import Tag from revengai.models.upload_file_type import UploadFileType -from revengai.models.base_response_upload_response import BaseResponseUploadResponse +from revengai.models.upload_output_body import UploadOutputBody from reai_toolkit.app.core.netstore_service import SimpleNetStore from reai_toolkit.app.core.shared_schema import GenericApiReturn @@ -90,7 +91,7 @@ def analyse_file( debug_sha256 = sha256_file(dp) # First, upload the file - upload_response: GenericApiReturn[BaseResponseUploadResponse] = self.upload_user_file( + upload_response: GenericApiReturn[UploadOutputBody] = self.upload_user_file( file_path=file_path, upload_file_type=UploadFileType.BINARY, # must match server UploadFileType force_overwrite=True, @@ -131,9 +132,9 @@ def analyse_file( def _get_max_upload_size(self) -> int: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: config_client: ConfigApi = ConfigApi(api_client) - response: BaseResponseConfigResponse = config_client.get_config() - if response.data: - return response.data.max_file_size_bytes + response: GetConfigOutputBody = config_client.v3_get_config() + if response.max_file_size_bytes: + return response.max_file_size_bytes return MAX_DEFAULT_FILE_SIZE_BYTES @@ -141,15 +142,13 @@ def _upload_file_req( self, upload_file_type: UploadFileType, file: Tuple[str, bytes], - packed_password: Optional[str] = None, force_overwrite: bool = False, - ) -> BaseResponseUploadResponse: + ) -> UploadOutputBody: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - analyses_client = AnalysesCoreApi(api_client) - return analyses_client.upload_file( - upload_file_type=UploadFileType(upload_file_type), + binaries_client = BinariesApi(api_client) + return binaries_client.v3_upload_file( + upload_file_type=UploadFileType(upload_file_type).value, force_overwrite=force_overwrite, - packed_password=packed_password, file=file, ) @@ -158,9 +157,8 @@ def upload_user_file( file_path: str, *, upload_file_type: UploadFileType, - packed_password: Optional[str] = None, force_overwrite: bool = False, - ) -> GenericApiReturn[BaseResponseUploadResponse]: + ) -> GenericApiReturn[UploadOutputBody]: p = Path(file_path) if not p.is_file(): return GenericApiReturn(success=False, error_message="File does not exist.") @@ -176,9 +174,9 @@ def upload_user_file( max_upload_size_mb: float = max_upload_size_bytes / (1024 * 1024) return GenericApiReturn(success=False, error_message=f"Failed to upload binary due to it exceeding maximum size limit of {max_upload_size_mb}MiB") - response: GenericApiReturn[BaseResponseUploadResponse] = self.api_request_returning( + response: GenericApiReturn[UploadOutputBody] = self.api_request_returning( lambda: self._upload_file_req( - upload_file_type, (p.name, blob), packed_password, force_overwrite + upload_file_type, (p.name, blob), force_overwrite ) ) diff --git a/tests/unit/ai_decomp/test_sdk_schemas.py b/tests/unit/ai_decomp/test_sdk_schemas.py index 2fd51a5..fd36d3f 100644 --- a/tests/unit/ai_decomp/test_sdk_schemas.py +++ b/tests/unit/ai_decomp/test_sdk_schemas.py @@ -5,9 +5,12 @@ import inspect from revengai import FunctionsAIDecompilationApi, FunctionsCoreApi +from revengai.models.ai_decompilation_rating import AiDecompilationRating from revengai.models.comments_data import CommentsData from revengai.models.create_ai_decomp_output_body import CreateAIDecompOutputBody from revengai.models.disassembly_output_body import DisassemblyOutputBody +from revengai.models.event_types_applied import EventTypesApplied +from revengai.models.event_types_suggested import EventTypesSuggested from revengai.models.line_attributions_data import LineAttributionsData from revengai.models.get_tokens_response import GetTokensResponse from revengai.models.inline_comment import InlineComment @@ -18,8 +21,14 @@ from revengai.models.token import Token from revengai.models.upsert_overrides_data import UpsertOverridesData from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody +from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress +from reai_toolkit.app.services.ai_decomp.stream import ( + EVENT_TYPES_APPLIED, + EVENT_TYPES_SUGGESTED, +) + def test_ai_decomp_api_exposes_the_methods_the_plugin_calls(): for method in ( @@ -34,7 +43,7 @@ def test_ai_decomp_api_exposes_the_methods_the_plugin_calls(): "regenerate_ai_decompilation_inline_comments", "patch_ai_decompilation_inline_comment", "delete_ai_decompilation_inline_comment", - "upsert_ai_decompilation_rating", + "v3_upsert_ai_decompilation_rating", "v3_get_ai_decompilation_tokens", "v3_upsert_ai_decompilation_overrides", "v3_get_ai_decompilation_line_attributions", @@ -79,6 +88,56 @@ def test_v3_token_methods_accept_plugin_kwargs(): assert {"function_id", "upsert_overrides_input_body"} <= set(overrides) +def test_v3_rating_method_accepts_plugin_kwargs(): + rating = inspect.signature( + FunctionsAIDecompilationApi.v3_upsert_ai_decompilation_rating + ).parameters + assert {"function_id", "upsert_rating_input_body"} <= set(rating) + + serialize = inspect.getsource( + FunctionsAIDecompilationApi._v3_upsert_ai_decompilation_rating_serialize + ) + assert "/v3/functions/{function_id}/ai-decompilation/rating" in serialize + + +def test_v3_rating_returns_nothing_so_success_is_the_absence_of_an_error(): + assert ( + inspect.signature( + FunctionsAIDecompilationApi.v3_upsert_ai_decompilation_rating + ).return_annotation + is None + ) + + +def test_upsert_rating_input_body_carries_rating_and_optional_reason(): + assert {"rating", "reason"} <= set(UpsertRatingInputBody.model_fields) + + body = UpsertRatingInputBody.from_json('{"rating": "POSITIVE"}') + assert body.rating == AiDecompilationRating.POSITIVE + assert body.reason is None + + +def test_upsert_rating_input_body_takes_the_rating_enum_the_plugin_holds(): + body = UpsertRatingInputBody(rating=AiDecompilationRating.NEGATIVE, reason=None) + assert body.to_dict() == {"rating": "NEGATIVE"} + + +def test_the_sse_type_events_carry_the_names_the_stream_parser_keys_off(): + suggested = EventTypesSuggested.from_json( + '{"event": "types_suggested", "data": {"attempt": 1, "seq": 4,' + ' "type": "types_suggested", "types": 2, "members": 7}}' + ) + assert suggested.event == EVENT_TYPES_SUGGESTED + assert (suggested.data.types, suggested.data.members) == (2, 7) + + applied = EventTypesApplied.from_json( + '{"event": "types_applied", "data": {"attempt": 1, "seq": 5,' + ' "type": "types_applied", "types": 2, "skipped": 1}}' + ) + assert applied.event == EVENT_TYPES_APPLIED + assert (applied.data.types, applied.data.skipped) == (2, 1) + + def test_workflow_progress_has_expected_fields(): fields = set(WorkflowProgress.model_fields.keys()) assert {"status", "step", "step_index", "steps_total", "messages"} <= fields diff --git a/tests/unit/ai_decomp/test_service.py b/tests/unit/ai_decomp/test_service.py index 44c6b36..c7f7526 100644 --- a/tests/unit/ai_decomp/test_service.py +++ b/tests/unit/ai_decomp/test_service.py @@ -630,7 +630,7 @@ def test_mutation_unknown_function_id_reports_failure(service, sdk, netstore): def test_rate_decomp_calls_upsert_rating(service, sdk): - sdk.upsert_ai_decompilation_rating.return_value = MagicMock() + sdk.v3_upsert_ai_decompilation_rating.return_value = None on_result = MagicMock() service.rate_decomp( @@ -638,16 +638,17 @@ def test_rate_decomp_calls_upsert_rating(service, sdk): ) _wait_mock(on_result) - _, kwargs = sdk.upsert_ai_decompilation_rating.call_args + _, kwargs = sdk.v3_upsert_ai_decompilation_rating.call_args assert kwargs["function_id"] == 42 - body = kwargs["upsert_ai_decomplation_rating_request"] + body = kwargs["upsert_rating_input_body"] assert body.rating == AiDecompilationRating.POSITIVE assert body.reason is None assert on_result.call_args[0][0].success is True + assert on_result.call_args[0][0].data is True def test_rate_decomp_api_error_surfaces(service, sdk): - sdk.upsert_ai_decompilation_rating.side_effect = ApiException(status=422) + sdk.v3_upsert_ai_decompilation_rating.side_effect = ApiException(status=422) on_result = MagicMock() service.rate_decomp( @@ -668,7 +669,7 @@ def test_rate_decomp_unknown_function_id_reports_failure(service, sdk, netstore) on_result.assert_called_once() assert on_result.call_args[0][0].success is False - sdk.upsert_ai_decompilation_rating.assert_not_called() + sdk.v3_upsert_ai_decompilation_rating.assert_not_called() def test_invalidate_clears_all_caches_and_inflight(service): diff --git a/tests/unit/ai_decomp/test_stream.py b/tests/unit/ai_decomp/test_stream.py index 226128f..cb2c1cf 100644 --- a/tests/unit/ai_decomp/test_stream.py +++ b/tests/unit/ai_decomp/test_stream.py @@ -12,6 +12,7 @@ EVENT_RENAME_APPLIED, EVENT_SOURCE_DELTA, EVENT_SOURCE_RESET, + EVENT_TYPES_APPLIED, EVENT_TYPES_SUGGESTED, EVENT_WARNING, DecompEvent, @@ -237,6 +238,28 @@ def test_renames_warnings_and_type_suggestions_are_collected(): assert (state.types_suggested, state.members_suggested) == (2, 7) +def test_types_applied_records_the_applied_and_skipped_counts(): + state = reduce_stream( + [ + _ev(EVENT_TYPES_SUGGESTED, types=3, members=9), + _ev(EVENT_TYPES_APPLIED, types=2, skipped=1), + ] + ) + + assert (state.types_applied, state.types_skipped) == (2, 1) + assert (state.types_suggested, state.members_suggested) == (3, 9) + + +def test_types_applied_frames_survive_the_parser(): + chunks = [_frame(EVENT_TYPES_APPLIED, types=2, skipped=1)] + + events = list(iter_stream_events(chunks)) + + assert [(e.type, e.types, e.skipped) for e in events] == [ + (EVENT_TYPES_APPLIED, 2, 1) + ] + + def test_a_warning_with_no_message_falls_back_to_its_kind(): state = reduce_stream([_ev(EVENT_WARNING, warning_kind="TRUNCATED")]) diff --git a/tests/unit/analysis_status/test_sdk_schemas.py b/tests/unit/analysis_status/test_sdk_schemas.py index c9ef268..273b8b2 100644 --- a/tests/unit/analysis_status/test_sdk_schemas.py +++ b/tests/unit/analysis_status/test_sdk_schemas.py @@ -1,13 +1,15 @@ +import typing + from revengai import ( AnalysisLogEntry, - BaseResponseStatus, + CreateMetadata, GetAnalysisLogsOutputBody, - StatusOutput, + OperationCreateMetadataCreateResult, ) -def test_status_output_fields(): - assert {"analysis_id", "analysis_status"} <= set(StatusOutput.model_fields) +def test_create_metadata_fields(): + assert {"analysis_id", "binary_id", "status"} <= set(CreateMetadata.model_fields) def test_logs_output_carries_entries(): @@ -18,5 +20,12 @@ def test_log_entry_fields(): assert {"text", "level", "timestamp"} <= set(AnalysisLogEntry.model_fields) -def test_base_response_status_envelope(): - assert {"status", "data", "errors"} <= set(BaseResponseStatus.model_fields) +def test_operation_fields(): + assert {"done", "error", "metadata", "name", "response"} <= set( + OperationCreateMetadataCreateResult.model_fields + ) + + +def test_operation_metadata_is_optional_create_metadata(): + annotation = OperationCreateMetadataCreateResult.model_fields["metadata"].annotation + assert set(typing.get_args(annotation)) == {CreateMetadata, type(None)} diff --git a/tests/unit/analysis_status/test_service.py b/tests/unit/analysis_status/test_service.py index 8cbdb23..114a8b0 100644 --- a/tests/unit/analysis_status/test_service.py +++ b/tests/unit/analysis_status/test_service.py @@ -3,8 +3,11 @@ import pytest from revengai import ApiException +from revengai.models.create_metadata import CreateMetadata from revengai.models.get_analysis_logs_output_body import GetAnalysisLogsOutputBody -from revengai.models.status_output import StatusOutput +from revengai.models.operation_create_metadata_create_result import ( + OperationCreateMetadataCreateResult, +) from reai_toolkit.app.services.analysis_status import analysis_status as svc_mod from reai_toolkit.app.services.analysis_status.analysis_status import ( @@ -12,6 +15,7 @@ ) ANALYSIS_ID = 4242 +BINARY_ID = 77 @pytest.fixture(autouse=True) @@ -41,9 +45,21 @@ def sdk(mocker): return api_inst -def _status(value: str): - return MagicMock( - data=StatusOutput.model_construct(analysis_id=ANALYSIS_ID, analysis_status=value) +def _operation(status: str): + return OperationCreateMetadataCreateResult( + done=status in ("Complete", "Error"), + name=f"operations/analyses/{ANALYSIS_ID}", + metadata=CreateMetadata( + analysis_id=ANALYSIS_ID, binary_id=BINARY_ID, status=status + ), + ) + + +def _operation_without_metadata(): + return OperationCreateMetadataCreateResult( + done=False, + name=f"operations/analyses/{ANALYSIS_ID}", + metadata=None, ) @@ -55,7 +71,7 @@ def _poll(service): def test_complete_status_reports_analysis_id(service, sdk, netstore): - sdk.get_analysis_status.return_value = _status("Complete") + sdk.v3_get_analysis_operation.return_value = _operation("Complete") cb = _poll(service) @@ -66,8 +82,16 @@ def test_complete_status_reports_analysis_id(service, sdk, netstore): netstore.put_analysis_status.assert_called_with("Complete") +def test_status_is_read_from_operation_metadata(service, sdk): + sdk.v3_get_analysis_operation.return_value = _operation("Complete") + + _poll(service) + + sdk.v3_get_analysis_operation.assert_called_with(analysis_id=ANALYSIS_ID) + + def test_error_status_reports_failure(service, sdk): - sdk.get_analysis_status.return_value = _status("Error") + sdk.v3_get_analysis_operation.return_value = _operation("Error") cb = _poll(service) @@ -77,7 +101,7 @@ def test_error_status_reports_failure(service, sdk): def test_status_request_failure_surfaces_error(service, sdk): - sdk.get_analysis_status.side_effect = ApiException(status=500, reason="boom") + sdk.v3_get_analysis_operation.side_effect = ApiException(status=500, reason="boom") cb = _poll(service) @@ -86,10 +110,30 @@ def test_status_request_failure_surfaces_error(service, sdk): sdk.v3_get_analysis_logs.assert_not_called() -def test_in_progress_status_keeps_polling_until_complete(service, sdk): - sdk.get_analysis_status.side_effect = [_status("Processing"), _status("Complete")] +def test_in_progress_status_keeps_polling_until_complete(service, sdk, netstore): + sdk.v3_get_analysis_operation.side_effect = [ + _operation("Processing"), + _operation("Complete"), + ] + + cb = _poll(service) + + assert sdk.v3_get_analysis_operation.call_count == 2 + assert cb.call_args[0][0].success is True + assert [call.args[0] for call in netstore.put_analysis_status.call_args_list] == [ + "Processing", + "Complete", + ] + + +def test_missing_metadata_keeps_polling_without_recording_status(service, sdk, netstore): + sdk.v3_get_analysis_operation.side_effect = [ + _operation_without_metadata(), + _operation("Complete"), + ] cb = _poll(service) - assert sdk.get_analysis_status.call_count == 2 + assert sdk.v3_get_analysis_operation.call_count == 2 assert cb.call_args[0][0].success is True + netstore.put_analysis_status.assert_called_once_with("Complete") diff --git a/tests/unit/analysis_sync/test_sdk_schemas.py b/tests/unit/analysis_sync/test_sdk_schemas.py index 8e172b8..33fb460 100644 --- a/tests/unit/analysis_sync/test_sdk_schemas.py +++ b/tests/unit/analysis_sync/test_sdk_schemas.py @@ -1,9 +1,8 @@ from revengai import ( AnalysisBasicInfoOutputBody, - BaseResponseAnalysisFunctionMapping, - FunctionMapping, + FunctionsCoreApi, + GetFunctionMapsOutputBody, ) -from revengai.models.analysis_function_mapping import AnalysisFunctionMapping def test_basic_info_carries_model_and_base_address(): @@ -12,12 +11,13 @@ def test_basic_info_carries_model_and_base_address(): ) -def test_function_mapping_envelope_carries_function_maps(): - assert {"status", "data"} <= set(BaseResponseAnalysisFunctionMapping.model_fields) - assert "function_maps" in AnalysisFunctionMapping.model_fields - - -def test_function_mapping_fields(): +def test_func_maps_output_is_flat(): assert {"function_map", "inverse_function_map", "name_map"} <= set( - FunctionMapping.model_fields + GetFunctionMapsOutputBody.model_fields ) + assert "data" not in GetFunctionMapsOutputBody.model_fields + assert "function_maps" not in GetFunctionMapsOutputBody.model_fields + + +def test_func_maps_lives_on_functions_core_api(): + assert hasattr(FunctionsCoreApi, "v3_get_analysis_func_maps") diff --git a/tests/unit/analysis_sync/test_service.py b/tests/unit/analysis_sync/test_service.py index 35c2f0e..d5134e0 100644 --- a/tests/unit/analysis_sync/test_service.py +++ b/tests/unit/analysis_sync/test_service.py @@ -5,6 +5,7 @@ AnalysisBasicInfoOutputBody, ) from revengai.models.function_mapping import FunctionMapping +from revengai.models.get_function_maps_output_body import GetFunctionMapsOutputBody from reai_toolkit.app.services.analysis_sync import analysis_sync as svc_mod from reai_toolkit.app.services.analysis_sync.analysis_sync import AnalysisSyncService @@ -266,3 +267,26 @@ def test_fetch_model_id_skips_rebase_when_base_address_unknown( service._fetch_model_id(1234) rebase.assert_not_called() + + +@pytest.fixture +def functions_api(mocker): + mocker.patch.object(AnalysisSyncService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "FunctionsCoreApi") + api = MagicMock() + api_class.return_value = api + return api + + +def test_fetch_function_map_reads_v3_func_maps(service, functions_api, netstore): + body = GetFunctionMapsOutputBody.model_construct( + function_map={"1": 0x1000}, + inverse_function_map={"4096": 1}, + name_map={"4096": "fn"}, + ) + functions_api.v3_get_analysis_func_maps.return_value = body + + assert service._fetch_function_map(1234) is body + + functions_api.v3_get_analysis_func_maps.assert_called_once_with(analysis_id=1234) + netstore.put_function_mapping.assert_called_once_with(body) diff --git a/tests/unit/auth/test_endpoint_auth.py b/tests/unit/auth/test_endpoint_auth.py index a12294e..9259846 100644 --- a/tests/unit/auth/test_endpoint_auth.py +++ b/tests/unit/auth/test_endpoint_auth.py @@ -83,7 +83,7 @@ def test_the_call_discovery_actually_finds_the_plugins_endpoints(): calls = _api_calls() assert "get_me" in calls.get("IAMUsersApi", set()) - assert "get_config" in calls.get("ConfigApi", set()) + assert "v3_get_config" in calls.get("ConfigApi", set()) assert sum(len(v) for v in calls.values()) > 40 diff --git a/tests/unit/auth/test_sdk_schemas.py b/tests/unit/auth/test_sdk_schemas.py index e437b64..9beae51 100644 --- a/tests/unit/auth/test_sdk_schemas.py +++ b/tests/unit/auth/test_sdk_schemas.py @@ -1,8 +1,12 @@ -from revengai import ConfigApi, IAMUsersApi, User +from revengai import ConfigApi, GetConfigOutputBody, IAMUsersApi, User def test_auth_verification_method_exists(): - assert callable(getattr(ConfigApi, "get_config", None)) + assert callable(getattr(ConfigApi, "v3_get_config", None)) + + +def test_config_output_body_exposes_max_file_size_bytes(): + assert "max_file_size_bytes" in GetConfigOutputBody.model_fields def test_iam_get_me_method_exists(): diff --git a/tests/unit/auth/test_service.py b/tests/unit/auth/test_service.py index 886f1fc..35c908e 100644 --- a/tests/unit/auth/test_service.py +++ b/tests/unit/auth/test_service.py @@ -67,7 +67,7 @@ def test_get_sdk_config_builds_once_and_mutates_in_place(service): def test_verify_success(service, config_api): - config_api.get_config.return_value = MagicMock() + config_api.v3_get_config.return_value = MagicMock() ok, msg = service.verify() @@ -77,7 +77,7 @@ def test_verify_success(service, config_api): def test_verify_api_exception_with_parsed_errors(service, config_api, mocker): - config_api.get_config.side_effect = ApiException(status=401) + config_api.v3_get_config.side_effect = ApiException(status=401) mocker.patch.object( svc_mod, "parse_exception", @@ -92,7 +92,7 @@ def test_verify_api_exception_with_parsed_errors(service, config_api, mocker): def test_verify_api_exception_without_parsed_body(service, config_api, mocker): - config_api.get_config.side_effect = ApiException(status=500, reason="boom") + config_api.v3_get_config.side_effect = ApiException(status=500, reason="boom") mocker.patch.object(svc_mod, "parse_exception", return_value=None) ok, msg = service.verify() @@ -103,7 +103,7 @@ def test_verify_api_exception_without_parsed_body(service, config_api, mocker): def test_verify_unexpected_exception(service, config_api): - config_api.get_config.side_effect = RuntimeError("network down") + config_api.v3_get_config.side_effect = RuntimeError("network down") ok, msg = service.verify() @@ -163,7 +163,7 @@ def test_is_enthusiast_false_when_user_unavailable(service, iam_api): def test_verify_success_warms_user_cache(service, config_api, iam_api): - config_api.get_config.return_value = MagicMock() + config_api.v3_get_config.return_value = MagicMock() iam_api.get_me.return_value = _user("ENTHUSIAST") ok, _ = service.verify() diff --git a/tests/unit/existing_analyses/test_sdk_schemas.py b/tests/unit/existing_analyses/test_sdk_schemas.py index 1466936..ae2640f 100644 --- a/tests/unit/existing_analyses/test_sdk_schemas.py +++ b/tests/unit/existing_analyses/test_sdk_schemas.py @@ -1,20 +1,24 @@ -from revengai.models import ( - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) -from revengai.models.analysis_record import AnalysisRecord +import inspect +from revengai import AnalysesCoreApi +from revengai.models.analysis_record_body import AnalysisRecordBody +from revengai.models.list_analyses_output_body import ListAnalysesOutputBody -def test_analysis_record_fields(): + +def test_analysis_record_body_fields(): assert {"analysis_id", "status", "base_address", "sha_256_hash"} <= set( - AnalysisRecord.model_fields + AnalysisRecordBody.model_fields + ) + + +def test_list_analyses_output_is_flat(): + assert {"results", "page_size", "next_page_token"} <= set( + ListAnalysesOutputBody.model_fields ) + assert "data" not in ListAnalysesOutputBody.model_fields -def test_list_analyses_enums_have_expected_members(): - assert "PERSONAL" in Workspace.__members__ - assert "COMPLETE" in StatusInput.__members__ - assert "DESC" in Order.__members__ - assert "CREATED" in AppApiRestV2AnalysesEnumsOrderBy.__members__ +def test_v3_list_analyses_accepts_expected_filters(): + params = inspect.signature(AnalysesCoreApi.v3_list_analyses).parameters + assert {"analysis_scope", "status", "sha256_hash", "order_by", "order"} <= set(params) + assert "workspace" not in params diff --git a/tests/unit/existing_analyses/test_service.py b/tests/unit/existing_analyses/test_service.py index 3e4c4f4..7bc6873 100644 --- a/tests/unit/existing_analyses/test_service.py +++ b/tests/unit/existing_analyses/test_service.py @@ -2,13 +2,7 @@ import pytest from revengai import ApiException -from revengai.models import ( - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) -from revengai.models.analysis_record import AnalysisRecord +from revengai.models.analysis_record_body import AnalysisRecordBody from reai_toolkit.app.services.existing_analyses import ( existing_analyses_service as svc_mod, @@ -33,28 +27,39 @@ def sdk(mocker): return api_inst -def _record(analysis_id: int) -> AnalysisRecord: - return AnalysisRecord.model_construct(analysis_id=analysis_id, status="Complete") +def _record(analysis_id: int) -> AnalysisRecordBody: + return AnalysisRecordBody.model_construct( + analysis_id=analysis_id, status="Complete" + ) def test_returns_records_and_passes_expected_filters(service, sdk): records = [_record(1), _record(2)] - sdk.list_analyses.return_value = MagicMock(data=MagicMock(results=records)) + sdk.v3_list_analyses.return_value = MagicMock(results=records) result = service.fetch_analyses_same_hash("/path/to/binary.elf") assert result.success is True assert result.data == records - kwargs = sdk.list_analyses.call_args.kwargs + kwargs = sdk.v3_list_analyses.call_args.kwargs assert kwargs["sha256_hash"] == "deadbeef" - assert kwargs["workspace"] == [Workspace.PERSONAL] - assert kwargs["status"] == [StatusInput.COMPLETE] - assert kwargs["order_by"] == AppApiRestV2AnalysesEnumsOrderBy.CREATED - assert kwargs["order"] == Order.DESC + assert kwargs["analysis_scope"] == ["PRIVATE"] + assert kwargs["status"] == ["Complete"] + assert kwargs["order_by"] == "created" + assert kwargs["order"] == "DESC" + + +def test_empty_page_returns_empty_list(service, sdk): + sdk.v3_list_analyses.return_value = MagicMock(results=None) + + result = service.fetch_analyses_same_hash("/path/to/binary.elf") + + assert result.success is True + assert result.data == [] def test_api_failure_is_passed_through(service, sdk): - sdk.list_analyses.side_effect = ApiException(status=500, reason="boom") + sdk.v3_list_analyses.side_effect = ApiException(status=500, reason="boom") result = service.fetch_analyses_same_hash("/path/to/binary.elf") diff --git a/tests/unit/matching/test_matching_service.py b/tests/unit/matching/test_matching_service.py index d5c88db..a352d94 100644 --- a/tests/unit/matching/test_matching_service.py +++ b/tests/unit/matching/test_matching_service.py @@ -37,9 +37,9 @@ def service(netstore): @pytest.fixture -def search_api(mocker): +def binaries_api(mocker): mocker.patch.object(MatchingService, "yield_api_client") - api_class = mocker.patch.object(svc_mod, "SearchApi") + api_class = mocker.patch.object(svc_mod, "BinariesApi") api_inst = MagicMock() api_class.return_value = api_inst return api_inst @@ -109,19 +109,19 @@ def test_search_collections_failure_returns_empty_success(service, collections_a assert out.data == [] -def test_search_binaries_queries_name_and_sha_excluding_current(service, search_api): - search_api.search_binaries.return_value = MagicMock( - data=MagicMock(results=[MagicMock()]) - ) +def test_search_binaries_queries_name_and_sha_excluding_current(service, binaries_api): + binaries_api.v3_search_binaries.return_value = MagicMock(results=[MagicMock()]) out = service.search_binaries("abc") assert out.success is True assert len(out.data) == 2 # one per call (name + sha) - assert search_api.search_binaries.call_count == 2 - for call in search_api.search_binaries.call_args_list: + assert binaries_api.v3_search_binaries.call_count == 2 + for call in binaries_api.v3_search_binaries.call_args_list: assert call.kwargs["exclude_binary_id"] == 7 assert call.kwargs["model_name"] == "binnet-0.1" + assert call.kwargs["limit"] == 5 + assert call.kwargs["offset"] == 0 def test_perform_matching_emits_start_batch_summary_and_filters_results(service, core_api): diff --git a/tests/unit/matching/test_sdk_schemas.py b/tests/unit/matching/test_sdk_schemas.py index ba929b2..07aa5b9 100644 --- a/tests/unit/matching/test_sdk_schemas.py +++ b/tests/unit/matching/test_sdk_schemas.py @@ -1,5 +1,5 @@ from revengai import ( - BinarySearchResult, + BinarySearchResultBody, CollectionListItemBody, FunctionMapping, FunctionMatch, @@ -61,7 +61,7 @@ def test_matched_function_fields(): def test_search_result_fields(): assert {"binary_id", "binary_name", "sha_256_hash"} <= set( - BinarySearchResult.model_fields + BinarySearchResultBody.model_fields ) assert {"collection_id", "collection_name"} <= set( CollectionListItemBody.model_fields diff --git a/tests/unit/upload/test_sdk_schemas.py b/tests/unit/upload/test_sdk_schemas.py new file mode 100644 index 0000000..0fc995a --- /dev/null +++ b/tests/unit/upload/test_sdk_schemas.py @@ -0,0 +1,27 @@ +import inspect + +from revengai import BinariesApi, UploadOutputBody + + +def test_upload_output_body_is_flat(): + assert { + "sha_256_hash", + "file_type", + "filename", + "mime", + "is_archive", + "can_sandbox", + "can_extract", + "analysis_requirements", + } <= set(UploadOutputBody.model_fields) + assert "data" not in UploadOutputBody.model_fields + + +def test_upload_lives_on_binaries_api(): + assert hasattr(BinariesApi, "v3_upload_file") + + +def test_v3_upload_file_has_no_packed_password(): + params = inspect.signature(BinariesApi.v3_upload_file).parameters + assert {"file", "upload_file_type", "force_overwrite"} <= set(params) + assert "packed_password" not in params diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index 7522a93..110f781 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from revengai import GetConfigOutputBody, UploadOutputBody from revengai.models.function_boundary import FunctionBoundary from revengai.models.symbols import Symbols from revengai.models.tag import Tag @@ -20,7 +21,25 @@ def sdk(mocker): mocker.patch.object(UploadService, "yield_api_client") api_class = mocker.patch.object(svc_mod, "AnalysesCoreApi") api_inst = MagicMock() - api_inst.upload_file.return_value = "parsed-upload-response" + api_class.return_value = api_inst + return api_inst + + +@pytest.fixture +def binaries(mocker): + mocker.patch.object(UploadService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "BinariesApi") + api_inst = MagicMock() + api_inst.v3_upload_file.return_value = "parsed-upload-response" + api_class.return_value = api_inst + return api_inst + + +@pytest.fixture +def config(mocker): + mocker.patch.object(UploadService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "ConfigApi") + api_inst = MagicMock() api_class.return_value = api_inst return api_inst @@ -35,39 +54,66 @@ def symbols(): ) -def test_upload_file_passes_tuple_to_sdk(service, sdk): +def test_upload_file_passes_tuple_to_v3_sdk(service, binaries): result = service._upload_file_req( upload_file_type=UploadFileType.BINARY, file=("binary.elf", b"\x7fELF\x02\x01"), force_overwrite=True, ) - sdk.upload_file.assert_called_once_with( - upload_file_type=UploadFileType.BINARY, + binaries.v3_upload_file.assert_called_once_with( + upload_file_type="BINARY", force_overwrite=True, - packed_password=None, file=("binary.elf", b"\x7fELF\x02\x01"), ) assert result == "parsed-upload-response" -def test_upload_file_forwards_packed_password(service, sdk): +def test_upload_file_sends_plain_string_file_type(service, binaries): service._upload_file_req( - upload_file_type=UploadFileType.BINARY, + upload_file_type=UploadFileType.DEBUG, file=("x", b""), - packed_password="hunter2", ) - assert sdk.upload_file.call_args.kwargs["packed_password"] == "hunter2" + sent = binaries.v3_upload_file.call_args.kwargs["upload_file_type"] + assert sent == "DEBUG" + assert type(sent) is str -def test_upload_file_defaults_force_overwrite_false(service, sdk): +def test_upload_file_defaults_force_overwrite_false(service, binaries): service._upload_file_req( upload_file_type=UploadFileType.BINARY, file=("x", b""), ) - assert sdk.upload_file.call_args.kwargs["force_overwrite"] is False + assert binaries.v3_upload_file.call_args.kwargs["force_overwrite"] is False + + +def test_upload_user_file_returns_flat_v3_body(service, binaries, mocker, tmp_path): + mocker.patch.object(UploadService, "_get_max_upload_size", return_value=1024) + binaries.v3_upload_file.return_value = UploadOutputBody( + analysis_requirements=[], + can_extract=False, + can_sandbox=False, + file_type="BINARY", + filename="binary.elf", + is_archive=False, + mime="application/x-executable", + sha_256_hash="deadbeef", + ) + target = tmp_path / "binary.elf" + target.write_bytes(b"\x7fELF") + + response = service.upload_user_file( + str(target), + upload_file_type=UploadFileType.BINARY, + force_overwrite=True, + ) + + assert response.success + assert response.data.sha_256_hash == "deadbeef" + assert response.data.file_type == "BINARY" + assert not hasattr(response.data, "data") @pytest.mark.parametrize( @@ -113,3 +159,22 @@ def test_analyse_defaults_no_cache_false(service, sdk, symbols): request = sdk.create_analysis.call_args.kwargs["analysis_create_request"] assert request.analysis_config.no_cache is False + + +def test_max_upload_size_reads_flat_v3_config(service, config): + config.v3_get_config.return_value = GetConfigOutputBody( + ai_decompiler_unsupported_languages=[], + max_file_size_bytes=42 * 1024 * 1024, + ) + + assert service._get_max_upload_size() == 42 * 1024 * 1024 + config.v3_get_config.assert_called_once_with() + + +def test_max_upload_size_falls_back_to_default(service, config): + config.v3_get_config.return_value = GetConfigOutputBody( + ai_decompiler_unsupported_languages=[], + max_file_size_bytes=0, + ) + + assert service._get_max_upload_size() == svc_mod.MAX_DEFAULT_FILE_SIZE_BYTES