From 06878f20a4b00d42dde21961e212068779564072 Mon Sep 17 00:00:00 2001 From: vgoat21 <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:20:27 +0000 Subject: [PATCH 1/5] chore: bump revengai to 4.72.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1cc4f0d..49ad11f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "plugin-binary-ninja" version = "0.0.1" requires-python = ">=3.10" dependencies = [ - "revengai>=4.4.0", + "revengai>=4.72.2", "urllib3>=2.0.0,<2.3.0", "libbs==2.15.1", "pydantic>=2.12.5", From 682b204d9e210854edce6d7095c8b5ed6714f581 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:42:31 +0100 Subject: [PATCH 2/5] fix: lock the SDK bump and build the vendor tree from uv.lock The bump that opened this PR changed pyproject.toml only -- the bump workflow has no lockfile step, so uv.lock still pinned revengai 4.4.0 while pyproject asked for >=4.72.2. Add the `uv lock --upgrade-package revengai` step (and uv itself, which that job did not install) so a bump lands as a lockfile change too, and commit the resulting lockfile here. The vendor tree under reai_toolkit/vendor is what the plugin actually imports: tests/conftest.py prepends it to sys.path ahead of site-packages, and the release artifact ships it. publish.yaml built it with `uv pip install .`, which ignores uv.lock and re-resolves from the pyproject floors, so releases shipped whatever the index happened to hold rather than the locked set. Between the two gaps the suite had been exercising the vendored 4.4.0 throughout. Add scripts/build_vendor.sh, which installs the exported locked runtime set, and scripts/check_vendor.py, which asserts the built tree matches uv.lock and runs on every rebuild. Environment markers are evaluated so Windows-only pins are not reported missing on other platforms. Point publish.yaml at the script. --- .github/workflows/bump-revengai-sdk.yaml | 8 ++- .github/workflows/publish.yaml | 5 +- scripts/build_vendor.sh | 30 +++++++++ scripts/check_vendor.py | 86 ++++++++++++++++++++++++ uv.lock | 8 +-- 5 files changed, 127 insertions(+), 10 deletions(-) create mode 100755 scripts/build_vendor.sh create mode 100644 scripts/check_vendor.py diff --git a/.github/workflows/bump-revengai-sdk.yaml b/.github/workflows/bump-revengai-sdk.yaml index c33276a..5029499 100644 --- a/.github/workflows/bump-revengai-sdk.yaml +++ b/.github/workflows/bump-revengai-sdk.yaml @@ -35,8 +35,8 @@ jobs: with: python-version: '3.14' - - name: Install packaging - run: pip install packaging + - name: Install tooling + run: pip install packaging uv - name: Inspect existing bump PR id: existing @@ -75,6 +75,10 @@ jobs: if: env.ABORT != 'true' && steps.bump.outputs.changed != 'true' run: echo "revengai is already at ${{ steps.bump.outputs.current_sdk }}; nothing to do." + - name: Sync uv lockfile + if: env.ABORT != 'true' && steps.bump.outputs.changed == 'true' + run: uv lock --upgrade-package revengai + - name: Open or update bump PR if: env.ABORT != 'true' && steps.bump.outputs.changed == 'true' uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 59617d6..c1a5e58 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -52,10 +52,7 @@ jobs: - name: Build vendor dependencies run: | rm -rf images - mkdir -p reai_toolkit/vendor - uv pip install . --target reai_toolkit/vendor --no-cache-dir - rm -rf reai_toolkit/vendor/reai_toolkit - rm -rf reai_toolkit/vendor/reai_toolkit-*.dist-info + ./scripts/build_vendor.sh shell: bash - name: Verify vendor folder contents diff --git a/scripts/build_vendor.sh b/scripts/build_vendor.sh new file mode 100755 index 0000000..8bceba6 --- /dev/null +++ b/scripts/build_vendor.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +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 -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 --no-project --with packaging python scripts/check_vendor.py + +echo "==> $VENDOR is prepended to sys.path ahead of site-packages," +echo " so rerun this after any dependency change or the old tree wins." diff --git a/scripts/check_vendor.py b/scripts/check_vendor.py new file mode 100644 index 0000000..fbb0128 --- /dev/null +++ b/scripts/check_vendor.py @@ -0,0 +1,86 @@ +import re +import subprocess +import sys +from pathlib import Path + +try: + from packaging.markers import Marker +except ModuleNotFoundError: + sys.exit( + "check_vendor.py needs the 'packaging' module to evaluate environment markers.\n" + "Run it the way scripts/build_vendor.sh does:\n" + " uv run --no-project --with packaging python scripts/check_vendor.py" + ) + +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 899cd71..8278edb 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ requires-dist = [ { name = "pycparser", specifier = ">=2.22,<3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "requests", specifier = ">=2.32" }, - { name = "revengai", specifier = ">=4.4.0" }, + { name = "revengai", specifier = ">=4.72.2" }, { name = "urllib3", specifier = ">=2.0.0,<2.3.0" }, ] @@ -674,7 +674,7 @@ wheels = [ [[package]] name = "revengai" -version = "4.4.0" +version = "4.72.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lazy-imports" }, @@ -683,9 +683,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/76/64dffda3361860419abde2aafaddb92ec03e2e35facab25b60b0f7ec8411/revengai-4.4.0.tar.gz", hash = "sha256:880b2994b4cb803d2339fd9fd69c62e779222b34a8a018197f44be9e4e456728", size = 407106, upload-time = "2026-08-24T07:50:06.817Z" } +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/45/a3/3a441a0fd5cd2cd9603890ec64d7c307b866ef46228c0517ba79a0f05886/revengai-4.4.0-py3-none-any.whl", hash = "sha256:d88dd348fdfb041802f13d542a1f91b86c6712ddef252aa4bf6a9b0d73461847", size = 1048901, upload-time = "2026-08-24T07:50:05.311Z" }, + { 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 5278a17cb7066b6bc830acdf2e410bec996cc04b Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:42:31 +0100 Subject: [PATCH 3/5] refactor: migrate the deprecated v2 endpoints onto v3 revengai 4.72.2 marks 85 operations deprecated, up from 25 in 4.4.0. Seven of the eight this plugin called now emit a DeprecationWarning at call time and have a v3 successor. Every v2 operation returned a BaseResponseX envelope with the payload on .data; every v3 operation returns a flat *OutputBody. So each migration is: swap the method, drop the .data unwrap, remap anything renamed. ConfigApi.get_config -> ConfigApi.v3_get_config ModelsApi.get_models -> ConfigApi.v3_get_models AnalysesCoreApi.get_analysis_basic_info -> AnalysesCoreApi.get_analysis_basic_info_0 AnalysesCoreApi.get_analysis_status -> AnalysesCoreApi.v3_get_analysis_operation AnalysesCoreApi.get_analysis_function_map -> FunctionsCoreApi.v3_get_analysis_func_maps AnalysesCoreApi.upload_file -> BinariesApi.v3_upload_file SearchApi.search_binaries -> BinariesApi.v3_search_binaries Notes on the non-obvious ones: SearchApi is now deprecated in its entirety -- it has zero non-deprecated operations -- so binary search moves to GET /v3/binaries, swapping page/page_size for limit/offset. BinarySearchResultBody carries the same field names as BinarySearchResult, so per-item access is unchanged. Analysis status moves to the long-running-operation endpoint GET /v3/operations/analyses/{analysis_id}; the status string is now at metadata.status. CreateMetadata.status is validated against the same vocabulary StatusInput already uses (Uploaded, Queued, Processing, Complete, Error), so the poller's comparisons are unchanged and a missing metadata takes the same path a non-terminal status does -- the existing behaviour of not treating a failed analysis as complete is preserved. v3_upload_file drops packed_password, which this plugin never passed, and returns file_type as a plain string rather than the UploadFileType enum; nothing compared that field. AnalysesCoreApi.create_analysis stays on v2: POST /v2/analyses is deprecated but has no v3 successor. It is now the only deprecated SDK call left in the plugin. tests/unit/sdk/test_sdk_schemas.py is retargeted at the v3 classes, methods and flat bodies it now depends on, so a later SDK bump that drops one of them fails there rather than at runtime. --- .../features/choose_source/choose_source.py | 6 +- reai_toolkit/features/configuration/config.py | 14 +-- reai_toolkit/features/upload/upload.py | 12 +-- reai_toolkit/utils/core/sync.py | 20 ++--- reai_toolkit/utils/features/matching.py | 13 ++- .../monitoring/process_binary_monitor.py | 11 +-- tests/headless/test_sync.py | 4 +- tests/headless/test_upload.py | 3 +- tests/unit/config/test_service.py | 39 +++++++- tests/unit/match_functions/test_service.py | 44 +++++++++ .../monitoring/test_process_binary_monitor.py | 89 +++++++++++++++++-- tests/unit/sdk/test_sdk_schemas.py | 74 ++++++++------- tests/unit/sync/test_service.py | 15 +++- tests/unit/upload/test_service.py | 28 ++++-- 14 files changed, 278 insertions(+), 94 deletions(-) diff --git a/reai_toolkit/features/choose_source/choose_source.py b/reai_toolkit/features/choose_source/choose_source.py index 142669d..c2b4712 100755 --- a/reai_toolkit/features/choose_source/choose_source.py +++ b/reai_toolkit/features/choose_source/choose_source.py @@ -45,9 +45,9 @@ def get_analysis(self, bv: BinaryView): sha256 = get_sha256(bv.file.filename) with self.config.create_api_client() as api_client: - api_instance = revengai.SearchApi(api_client) - api_response = api_instance.search_binaries(partial_sha256 = sha256, user_files_only = True) - results = api_response.data.results + api_instance = revengai.BinariesApi(api_client) + api_response = api_instance.v3_search_binaries(partial_sha256 = sha256, user_files_only = True) + results = api_response.results or [] if not len(results): raise Exception("Binary not found in RevEng.AI, try processing the binary again.") diff --git a/reai_toolkit/features/configuration/config.py b/reai_toolkit/features/configuration/config.py index eb17dcf..637c256 100755 --- a/reai_toolkit/features/configuration/config.py +++ b/reai_toolkit/features/configuration/config.py @@ -112,7 +112,7 @@ def check_auth(self): self.init_api_config() with self.create_api_client() as api_client: api_instance = revengai.ConfigApi(api_client) - api_instance.get_config() + api_instance.v3_get_config() log_info("RevEng.AI | User authenticated successfully.") return True except Exception as e: @@ -172,14 +172,14 @@ def init_config(self, bv: BinaryView): else: log_info(f"RevEng.AI | Binary not found in saved configurations, searching in RevEng.AI...") with self.create_api_client() as api_client: - api_instance = revengai.SearchApi(api_client) - api_response = api_instance.search_binaries(partial_sha256 = self.sha256, user_files_only = True) - if not len(api_response.data.results): + api_instance = revengai.BinariesApi(api_client) + api_response = api_instance.v3_search_binaries(partial_sha256 = self.sha256, user_files_only = True) + if not len(api_response.results): raise Exception("Binary not found in RevEng.AI, try processing the binary again.") else: - self.binary_id = api_response.data.results[0].binary_id - self.analysis_id = api_response.data.results[0].analysis_id - self.model_id = api_response.data.results[0].model_id + self.binary_id = api_response.results[0].binary_id + self.analysis_id = api_response.results[0].analysis_id + self.model_id = api_response.results[0].model_id bv = AnalysisSyncService(self).sync_analysis_data(analysis_id=self.analysis_id, bv=bv) log_info(f"RevEng.AI | Binary found in RevEng.AI, binary_id: {self.binary_id}") self.set_current_info(self.binary_id, self.analysis_id, self.model_id) diff --git a/reai_toolkit/features/upload/upload.py b/reai_toolkit/features/upload/upload.py index 06b10e5..d1622f5 100755 --- a/reai_toolkit/features/upload/upload.py +++ b/reai_toolkit/features/upload/upload.py @@ -31,9 +31,9 @@ def get_user_tier(self): def get_models(self, bv: BinaryView): try: with self.config.create_api_client() as api_client: - api_instance = revengai.ModelsApi(api_client) - api_response = api_instance.get_models() - models = api_response.data.models + api_instance = revengai.ConfigApi(api_client) + api_response = api_instance.v3_get_models() + models = api_response.models or [] log_info(f"RevEng.AI | Models: {models}") return True, models except Exception as e: @@ -44,10 +44,10 @@ def upload_file(self, file_path: str, file_type: revengai.UploadFileType): try: file = Path(file_path).read_bytes() with self.config.create_api_client() as api_client: - api_instance = revengai.AnalysesCoreApi(api_client) - api_response = api_instance.upload_file(file_type, file, force_overwrite=True) + api_instance = revengai.BinariesApi(api_client) + api_response = api_instance.v3_upload_file(file, file_type.value, force_overwrite=True) log_info(f"RevEng.AI | File uploaded successfully.") - return True, api_response.data.sha_256_hash + return True, api_response.sha_256_hash except Exception as e: log_error(f"RevEng.AI | Failed to upload file {file_path}: {str(e)}") return False, str(e) diff --git a/reai_toolkit/utils/core/sync.py b/reai_toolkit/utils/core/sync.py index 267c984..290a01b 100644 --- a/reai_toolkit/utils/core/sync.py +++ b/reai_toolkit/utils/core/sync.py @@ -1,6 +1,6 @@ from binaryninja import log_info, BinaryView -from revengai import AnalysesCoreApi, Configuration, FunctionMapping -from revengai import BaseResponseBasic, AnalysesCoreApi, ApiClient +from revengai import AnalysesCoreApi, Configuration, FunctionsCoreApi +from revengai import AnalysisBasicInfoOutputBody, GetFunctionMapsOutputBody, ApiClient from binaryninja import BinaryView, log_error, log_info, Symbol, SymbolType, Function class AnalysisSyncService: @@ -26,14 +26,14 @@ def _fetch_basic_and_rebase(self, bv: BinaryView, analysis_id: int) -> BinaryVie """ with ApiClient(self.sdk_config) as api_client: analyses_client = AnalysesCoreApi(api_client) - analysis_details: BaseResponseBasic = analyses_client.get_analysis_basic_info( + analysis_details: AnalysisBasicInfoOutputBody = analyses_client.get_analysis_basic_info_0( analysis_id=analysis_id ) - if not (analysis_details.data and analysis_details.data.base_address is not None): + if analysis_details.base_address is None: return bv - remote_base_address: int = analysis_details.data.base_address + remote_base_address: int = analysis_details.base_address local_base_address: int = self._get_current_base_address(bv) if local_base_address == remote_base_address: @@ -41,22 +41,20 @@ def _fetch_basic_and_rebase(self, bv: BinaryView, analysis_id: int) -> BinaryVie return self._rebase_program(bv, remote_base_address - local_base_address) - 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 ApiClient(self.sdk_config) as api_client: - analyses_client = AnalysesCoreApi(api_client) + functions_client = FunctionsCoreApi(api_client) - function_map = analyses_client.get_analysis_function_map( + return functions_client.v3_get_analysis_func_maps( analysis_id=analysis_id ) - func_map = function_map.data.function_maps - return func_map def _match_functions( self, - func_map: FunctionMapping, + func_map: GetFunctionMapsOutputBody, bv: BinaryView, ) -> None: function_map = func_map.function_map diff --git a/reai_toolkit/utils/features/matching.py b/reai_toolkit/utils/features/matching.py index bc08180..d234f4d 100755 --- a/reai_toolkit/utils/features/matching.py +++ b/reai_toolkit/utils/features/matching.py @@ -131,17 +131,16 @@ def _search_binaries(self, query: Dict[str, Any] = {}): try: log_info("RevEng.AI | Searching for binaries") with self.config.create_api_client() as api_client: - api_instance = revengai.SearchApi(api_client) - api_response = api_instance.search_binaries( - page = 1, - page_size = 20, + api_instance = revengai.BinariesApi(api_client) + api_response = api_instance.v3_search_binaries( partial_name = query.get("binary_name") , partial_sha256 = query.get("sha_256_hash"), tags = query.get("tags"), - model_name = query.get("model_name") - + model_name = query.get("model_name"), + limit = 20, + offset = 0, ) - for binary in api_response.data.results: + for binary in api_response.results or []: item = { "name": binary.binary_name, "binary_id": str(binary.binary_id), diff --git a/reai_toolkit/utils/monitoring/process_binary_monitor.py b/reai_toolkit/utils/monitoring/process_binary_monitor.py index ffd494b..b8f2d0c 100755 --- a/reai_toolkit/utils/monitoring/process_binary_monitor.py +++ b/reai_toolkit/utils/monitoring/process_binary_monitor.py @@ -42,8 +42,9 @@ def _worker(bv: BinaryView, bid: int, aid: int): try: with self.config.create_api_client() as api_client: api_instance = revengai.AnalysesCoreApi(api_client) - api_response = api_instance.get_analysis_status(aid) - status = api_response.data.analysis_status + api_response = api_instance.v3_get_analysis_operation(aid) + metadata = api_response.metadata + status = metadata.status if metadata else None log_info( f"RevEng.AI | Current status for analysis [Binary ID: {bid}] [Analysis ID: {aid}]: {status}" ) @@ -61,12 +62,12 @@ def _worker(bv: BinaryView, bid: int, aid: int): # Analysis is complete, fetch model_id and invoke callback with self.config.create_api_client() as api_client: api_instance = revengai.AnalysesCoreApi(api_client) - analysis_details: revengai.BaseResponseBasic = ( - api_instance.get_analysis_basic_info( + analysis_details: revengai.AnalysisBasicInfoOutputBody = ( + api_instance.get_analysis_basic_info_0( analysis_id=analysis_id ) ) - model_id = analysis_details.data.model_id + model_id = analysis_details.model_id callback(bid, aid, model_id) bv = self.sync_service.sync_analysis_data( diff --git a/tests/headless/test_sync.py b/tests/headless/test_sync.py index 93ae99c..9b254b4 100644 --- a/tests/headless/test_sync.py +++ b/tests/headless/test_sync.py @@ -47,7 +47,7 @@ def test_rebase_returns_view_at_analysis_base(bv, mocker): mocker.patch.object(sync_mod, "ApiClient") api = mocker.patch.object(sync_mod, "AnalysesCoreApi").return_value target = bv.start + 0x100000 - api.get_analysis_basic_info.return_value.data.base_address = target + api.get_analysis_basic_info_0.return_value.base_address = target rebased = service._fetch_basic_and_rebase(bv, analysis_id=1) @@ -59,7 +59,7 @@ def test_rebase_skipped_when_already_aligned(bv, mocker): service = sync_mod.AnalysisSyncService(MagicMock()) mocker.patch.object(sync_mod, "ApiClient") api = mocker.patch.object(sync_mod, "AnalysesCoreApi").return_value - api.get_analysis_basic_info.return_value.data.base_address = bv.start + api.get_analysis_basic_info_0.return_value.base_address = bv.start result = service._fetch_basic_and_rebase(bv, analysis_id=1) diff --git a/tests/headless/test_upload.py b/tests/headless/test_upload.py index d9ff7b5..31c9bf0 100644 --- a/tests/headless/test_upload.py +++ b/tests/headless/test_upload.py @@ -11,7 +11,8 @@ def test_upload_binary_collects_real_function_boundaries(bv, mocker): uploader = upload_mod.BinaryUploader(MagicMock()) api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value - api.upload_file.return_value.data.sha_256_hash = "abcd" + binaries = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value + binaries.v3_upload_file.return_value.sha_256_hash = "abcd" api.create_analysis.return_value.data.analysis_id = 1 api.create_analysis.return_value.data.binary_id = 2 mocker.patch.object(upload_mod, "PeriodicChecker") diff --git a/tests/unit/config/test_service.py b/tests/unit/config/test_service.py index 08d1617..2ab8808 100644 --- a/tests/unit/config/test_service.py +++ b/tests/unit/config/test_service.py @@ -45,19 +45,52 @@ def config(mocker, settings): def test_check_auth_true_when_get_config_succeeds(config, mocker): api = mocker.patch.object(config_mod.revengai, "ConfigApi").return_value - api.get_config.return_value = MagicMock() + api.v3_get_config.return_value = MagicMock() assert config.check_auth() is True - api.get_config.assert_called_once() + api.v3_get_config.assert_called_once() def test_check_auth_false_when_get_config_raises(config, mocker): api = mocker.patch.object(config_mod.revengai, "ConfigApi").return_value - api.get_config.side_effect = Exception("401 unauthorized") + api.v3_get_config.side_effect = Exception("401 unauthorized") assert config.check_auth() is False +def test_init_config_uses_v3_binary_search(config, mocker): + mocker.patch.object(config_mod, "get_sha256", return_value="deadbeef") + mocker.patch.object(config, "check_auth", return_value=True) + sync = mocker.patch.object(config_mod, "AnalysisSyncService") + api = mocker.patch.object(config_mod.revengai, "BinariesApi").return_value + api.v3_search_binaries.return_value = MagicMock( + results=[MagicMock(binary_id=11, analysis_id=22, model_id=33)] + ) + config.api_key = "key" + + ok, _ = config.init_config(MagicMock()) + + assert ok is True + api.v3_search_binaries.assert_called_once_with( + partial_sha256="deadbeef", user_files_only=True + ) + assert (config.binary_id, config.analysis_id, config.model_id) == (11, 22, 33) + sync.return_value.sync_analysis_data.assert_called_once() + + +def test_init_config_raises_when_v3_search_returns_no_results(config, mocker): + mocker.patch.object(config_mod, "get_sha256", return_value="deadbeef") + mocker.patch.object(config, "check_auth", return_value=True) + api = mocker.patch.object(config_mod.revengai, "BinariesApi").return_value + api.v3_search_binaries.return_value = MagicMock(results=[]) + config.api_key = "key" + + ok, message = config.init_config(MagicMock()) + + assert ok is False + assert "not found" in message + + def test_set_current_info_round_trips_through_settings(config): config.sha256 = "abc123" diff --git a/tests/unit/match_functions/test_service.py b/tests/unit/match_functions/test_service.py index 0ac90fd..4ee8061 100644 --- a/tests/unit/match_functions/test_service.py +++ b/tests/unit/match_functions/test_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from unittest.mock import MagicMock import pytest @@ -84,3 +85,46 @@ def test_match_functions_lists_analyzed_functions_via_list_analysis_functions(mo assert 42 in call.args or call.kwargs.get("analysis_id") == 42 assert ok is True assert result["skipped"] == 0 + + +def test_search_binaries_uses_v3_binaries_endpoint(mocker): + config = MagicMock() + + old_api = mocker.patch.object(revengai, "SearchApi").return_value + old_api.search_binaries.side_effect = AssertionError( + "binary search must go through BinariesApi.v3_search_binaries, not the " + "deprecated SearchApi.search_binaries" + ) + + created_at = datetime(2024, 5, 1, 12, 30) + binaries_api = mocker.patch.object(revengai, "BinariesApi").return_value + binaries_api.v3_search_binaries.return_value = MagicMock( + results=[ + MagicMock( + binary_name="libc.so", + binary_id=11, + analysis_id=22, + sha_256_hash="abc", + owned_by="someone", + created_at=created_at, + ) + ] + ) + + feature = mf_mod.MatchFunctions(config) + items = feature._search_binaries({"binary_name": "libc"}) + + call = binaries_api.v3_search_binaries.call_args + assert call.kwargs["limit"] == 20 + assert call.kwargs["offset"] == 0 + assert "page" not in call.kwargs and "page_size" not in call.kwargs + assert items == [ + { + "name": "libc.so", + "binary_id": "11", + "analysis_id": "22", + "sha_256_hash": "abc", + "owner": "someone", + "date": created_at.strftime("%m/%d/%Y %H:%M"), + } + ] diff --git a/tests/unit/monitoring/test_process_binary_monitor.py b/tests/unit/monitoring/test_process_binary_monitor.py index b8270df..a4b1d80 100644 --- a/tests/unit/monitoring/test_process_binary_monitor.py +++ b/tests/unit/monitoring/test_process_binary_monitor.py @@ -1,9 +1,12 @@ +from datetime import datetime, timezone from unittest.mock import MagicMock import pytest pytest.importorskip("binaryninja") +import revengai + from reai_toolkit.utils.monitoring import process_binary_monitor as pbm_mod @@ -13,6 +16,51 @@ def _bv(): return bv +def _operation(status, done=False): + return revengai.OperationCreateMetadataCreateResult( + done=done, + name="operations/analyses/2", + metadata=revengai.CreateMetadata(analysis_id=2, binary_id=1, status=status), + ) + + +def _operation_without_metadata(): + return revengai.OperationCreateMetadataCreateResult( + done=False, + name="operations/analyses/2", + metadata=None, + ) + + +def _basic_info(model_id): + return revengai.AnalysisBasicInfoOutputBody( + analysis_scope="PRIVATE", + base_address=4194304, + binary_id=1, + binary_name="sample.elf", + binary_size=1024, + binary_uuid="0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0", + creation=datetime(2024, 1, 1, tzinfo=timezone.utc), + debug=False, + detected_architecture="x86_64", + detected_binary_format="ELF", + detected_binary_type="linux", + function_count=12, + is_advanced=False, + is_owner=True, + is_system=False, + model_id=model_id, + model_name="binnet", + model_upgrade_available=False, + owner_username="tester", + sha_256_hash="0" * 64, + supplied_architecture="AUTO", + supplied_binary_format="AUTO", + supplied_binary_type="AUTO", + team_id=7, + ) + + def test_worker_completes_invokes_callback_and_syncs(mocker): captured = {} @@ -23,8 +71,8 @@ def fake_timer(interval, function, args=()): mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value - api.get_analysis_status.return_value.data.analysis_status = "Complete" - api.get_analysis_basic_info.return_value.data.model_id = 5 + api.v3_get_analysis_operation.return_value = _operation("Complete", done=True) + api.get_analysis_basic_info_0.return_value = _basic_info(5) checker = pbm_mod.PeriodicChecker(MagicMock()) checker.sync_service = MagicMock() @@ -33,6 +81,8 @@ def fake_timer(interval, function, args=()): checker.start_checking(_bv(), analysis_id=2, binary_id=1, callback=callback) captured["function"](*captured["args"]) + api.v3_get_analysis_operation.assert_called_once_with(2) + api.get_analysis_basic_info_0.assert_called_once_with(analysis_id=2) callback.assert_called_once_with(1, 2, 5) checker.sync_service.sync_analysis_data.assert_called_once() @@ -46,7 +96,7 @@ def fake_timer(interval, function, args=()): mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value - api.get_analysis_status.return_value.data.analysis_status = "Processing" + api.v3_get_analysis_operation.return_value = _operation("Processing") checker = pbm_mod.PeriodicChecker(MagicMock()) checker.sync_service = MagicMock() @@ -69,7 +119,7 @@ def fake_timer(interval, function, args=()): mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value - api.get_analysis_status.return_value.data.analysis_status = status + api.v3_get_analysis_operation.return_value = _operation(status) checker = pbm_mod.PeriodicChecker(MagicMock()) checker.sync_service = MagicMock() @@ -91,7 +141,7 @@ def fake_timer(interval, function, args=()): mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value - api.get_analysis_status.return_value.data.analysis_status = "Error" + api.v3_get_analysis_operation.return_value = _operation("Error", done=True) checker = pbm_mod.PeriodicChecker(MagicMock()) checker.sync_service = MagicMock() @@ -103,6 +153,7 @@ def fake_timer(interval, function, args=()): assert len(timers) == before callback.assert_not_called() + api.get_analysis_basic_info_0.assert_not_called() checker.sync_service.sync_analysis_data.assert_not_called() @@ -115,7 +166,32 @@ def fake_timer(interval, function, args=()): mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value - api.get_analysis_status.return_value.data.analysis_status = "Weird" + api.v3_get_analysis_operation.return_value = _operation("unknown_default_open_api") + + checker = pbm_mod.PeriodicChecker(MagicMock()) + checker.sync_service = MagicMock() + callback = MagicMock() + + checker.start_checking(_bv(), analysis_id=2, binary_id=1, callback=callback) + before = len(timers) + timers[-1][1](*timers[-1][2]) + + assert len(timers) == before + callback.assert_not_called() + api.get_analysis_basic_info_0.assert_not_called() + checker.sync_service.sync_analysis_data.assert_not_called() + + +def test_worker_without_metadata_does_not_complete(mocker): + timers = [] + + def fake_timer(interval, function, args=()): + timers.append((interval, function, args)) + return MagicMock() + + mocker.patch.object(pbm_mod, "Timer", side_effect=fake_timer) + api = mocker.patch.object(pbm_mod.revengai, "AnalysesCoreApi").return_value + api.v3_get_analysis_operation.return_value = _operation_without_metadata() checker = pbm_mod.PeriodicChecker(MagicMock()) checker.sync_service = MagicMock() @@ -127,4 +203,5 @@ def fake_timer(interval, function, args=()): assert len(timers) == before callback.assert_not_called() + api.get_analysis_basic_info_0.assert_not_called() checker.sync_service.sync_analysis_data.assert_not_called() diff --git a/tests/unit/sdk/test_sdk_schemas.py b/tests/unit/sdk/test_sdk_schemas.py index eeed6eb..e46500d 100644 --- a/tests/unit/sdk/test_sdk_schemas.py +++ b/tests/unit/sdk/test_sdk_schemas.py @@ -3,19 +3,17 @@ import pytest import revengai +from revengai.models.analysis_basic_info_output_body import AnalysisBasicInfoOutputBody from revengai.models.analysis_function_entry import AnalysisFunctionEntry -from revengai.models.analysis_function_mapping import AnalysisFunctionMapping -from revengai.models.basic import Basic -from revengai.models.binary_search_result import BinarySearchResult +from revengai.models.binary_search_result_body import BinarySearchResultBody from revengai.models.comments_data import CommentsData from revengai.models.create_ai_decomp_output_body import CreateAIDecompOutputBody from revengai.models.decompilation_data import DecompilationData -from revengai.models.function_mapping import FunctionMapping from revengai.models.function_match import FunctionMatch +from revengai.models.get_function_maps_output_body import GetFunctionMapsOutputBody from revengai.models.inline_comment import InlineComment from revengai.models.matched_function import MatchedFunction from revengai.models.rename_input_body import RenameInputBody -from revengai.models.status_output import StatusOutput from revengai.models.summary_data import SummaryData from revengai.models.task_status import TaskStatus from revengai.models.workflow_progress import WorkflowProgress @@ -23,22 +21,20 @@ PINNED = (4, 4, 0) API_METHODS = { - "ConfigApi": ["get_config"], - "SearchApi": ["search_binaries"], + "ConfigApi": ["v3_get_config", "v3_get_models"], + "BinariesApi": ["v3_upload_file", "v3_search_binaries"], "CollectionsApi": ["v3_list_collections"], "AnalysesCoreApi": [ - "upload_file", "create_analysis", - "get_analysis_status", - "get_analysis_basic_info", - "get_analysis_function_map", + "v3_get_analysis_operation", + "get_analysis_basic_info_0", "start_analysis_function_matching", "get_analysis_function_matching_status", "get_analysis_function_matches", ], - "ModelsApi": ["get_models"], "FunctionsCoreApi": [ "list_analysis_functions", + "v3_get_analysis_func_maps", "start_functions_matching", "get_functions_matching_status", "get_functions_matches", @@ -90,9 +86,13 @@ def test_search_binaries_accepts_plugin_kwargs(): "partial_name", "tags", "model_name", - "page", - "page_size", - } <= _params(revengai.SearchApi.search_binaries) + "limit", + "offset", + } <= _params(revengai.BinariesApi.v3_search_binaries) + + +def test_search_binaries_output_has_no_envelope(): + assert {"results"} <= set(revengai.SearchBinariesOutputBody.model_fields) def test_v3_list_collections_accepts_plugin_kwargs(): @@ -118,10 +118,18 @@ def test_collection_list_item_has_plugin_fields(): def test_upload_file_accepts_plugin_kwargs(): assert {"upload_file_type", "file", "force_overwrite"} <= _params( - revengai.AnalysesCoreApi.upload_file + revengai.BinariesApi.v3_upload_file ) +def test_upload_output_has_no_envelope(): + assert "sha_256_hash" in revengai.UploadOutputBody.model_fields + + +def test_get_models_output_has_no_envelope(): + assert "models" in revengai.GetModelsOutputBody.model_fields + + def test_create_analysis_accepts_request_kwarg(): assert "analysis_create_request" in _params( revengai.AnalysesCoreApi.create_analysis @@ -129,12 +137,11 @@ def test_create_analysis_accepts_request_kwarg(): def test_analysis_id_methods_accept_analysis_id(): - for method in ( - "get_analysis_status", - "get_analysis_basic_info", - "get_analysis_function_map", - ): + for method in ("v3_get_analysis_operation", "get_analysis_basic_info_0"): assert "analysis_id" in _params(getattr(revengai.AnalysesCoreApi, method)) + assert "analysis_id" in _params( + revengai.FunctionsCoreApi.v3_get_analysis_func_maps + ) def test_list_analysis_functions_accepts_analysis_id(): @@ -239,24 +246,27 @@ def test_binary_search_result_has_plugin_fields(): "model_name", "owned_by", "created_at", - } <= set(BinarySearchResult.model_fields) - + } <= set(BinarySearchResultBody.model_fields) -def test_status_output_exposes_analysis_status(): - assert "analysis_status" in StatusOutput.model_fields - -def test_basic_exposes_base_address_and_model_id(): - assert {"base_address", "model_id"} <= set(Basic.model_fields) +def test_analysis_operation_exposes_create_metadata(): + assert {"done", "error", "metadata", "name", "response"} <= set( + revengai.OperationCreateMetadataCreateResult.model_fields + ) + assert {"analysis_id", "binary_id", "status"} <= set( + revengai.CreateMetadata.model_fields + ) -def test_analysis_function_mapping_exposes_function_maps(): - assert "function_maps" in AnalysisFunctionMapping.model_fields +def test_analysis_basic_info_has_plugin_fields(): + assert {"base_address", "model_id", "binary_id", "binary_name"} <= set( + AnalysisBasicInfoOutputBody.model_fields + ) -def test_function_mapping_has_plugin_fields(): +def test_function_maps_output_has_plugin_fields(): assert {"function_map", "inverse_function_map", "name_map"} <= set( - FunctionMapping.model_fields + GetFunctionMapsOutputBody.model_fields ) diff --git a/tests/unit/sync/test_service.py b/tests/unit/sync/test_service.py index 4ee590d..2966ebd 100644 --- a/tests/unit/sync/test_service.py +++ b/tests/unit/sync/test_service.py @@ -52,7 +52,7 @@ def test_fetch_basic_and_rebase_rebases_on_delta(mocker): service = sync_mod.AnalysisSyncService(MagicMock()) mocker.patch.object(sync_mod, "ApiClient") api = mocker.patch.object(sync_mod, "AnalysesCoreApi").return_value - api.get_analysis_basic_info.return_value.data.base_address = 0x400000 + api.get_analysis_basic_info_0.return_value.base_address = 0x400000 bv = MagicMock() bv.start = 0x0 @@ -65,10 +65,21 @@ def test_fetch_basic_and_rebase_noop_when_aligned(mocker): service = sync_mod.AnalysisSyncService(MagicMock()) mocker.patch.object(sync_mod, "ApiClient") api = mocker.patch.object(sync_mod, "AnalysesCoreApi").return_value - api.get_analysis_basic_info.return_value.data.base_address = 0x400000 + api.get_analysis_basic_info_0.return_value.base_address = 0x400000 bv = MagicMock() bv.start = 0x400000 service._fetch_basic_and_rebase(bv, analysis_id=7) bv.rebase.assert_not_called() + + +def test_fetch_function_map_uses_v3_endpoint(mocker): + service = sync_mod.AnalysisSyncService(MagicMock()) + mocker.patch.object(sync_mod, "ApiClient") + api = mocker.patch.object(sync_mod, "FunctionsCoreApi").return_value + + func_map = service._fetch_function_map(analysis_id=7) + + api.v3_get_analysis_func_maps.assert_called_once_with(analysis_id=7) + assert func_map is api.v3_get_analysis_func_maps.return_value diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index ee28059..9d525b1 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -13,8 +13,8 @@ def uploader(): def test_get_models_returns_models(uploader, mocker): - api = mocker.patch.object(upload_mod.revengai, "ModelsApi").return_value - api.get_models.return_value.data.models = ["binnet-0.5", "binnet-0.4"] + api = mocker.patch.object(upload_mod.revengai, "ConfigApi").return_value + api.v3_get_models.return_value.models = ["binnet-0.5", "binnet-0.4"] ok, models = uploader.get_models(MagicMock()) @@ -22,9 +22,19 @@ def test_get_models_returns_models(uploader, mocker): assert models == ["binnet-0.5", "binnet-0.4"] +def test_get_models_handles_missing_models(uploader, mocker): + api = mocker.patch.object(upload_mod.revengai, "ConfigApi").return_value + api.v3_get_models.return_value.models = None + + ok, models = uploader.get_models(MagicMock()) + + assert ok is True + assert models == [] + + def test_upload_file_returns_sha_and_forwards_args(uploader, mocker, tmp_path): - api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value - api.upload_file.return_value.data.sha_256_hash = "deadbeef" + api = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value + api.v3_upload_file.return_value.sha_256_hash = "deadbeef" binary = tmp_path / "bin.elf" binary.write_bytes(b"\x7fELF\x02\x01") @@ -32,15 +42,15 @@ def test_upload_file_returns_sha_and_forwards_args(uploader, mocker, tmp_path): assert ok is True assert sha == "deadbeef" - args, kwargs = api.upload_file.call_args - assert args[0] == upload_mod.revengai.UploadFileType.BINARY - assert args[1] == b"\x7fELF\x02\x01" + args, kwargs = api.v3_upload_file.call_args + assert args[0] == b"\x7fELF\x02\x01" + assert args[1] == upload_mod.revengai.UploadFileType.BINARY.value assert kwargs["force_overwrite"] is True def test_upload_file_surfaces_error(uploader, mocker, tmp_path): - api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value - api.upload_file.side_effect = Exception("boom") + api = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value + api.v3_upload_file.side_effect = Exception("boom") binary = tmp_path / "bin.elf" binary.write_bytes(b"\x7fELF") From 3e8e4ac1cb0de6022e4e58a2d6cf406d6c3e1f6e Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:12:12 +0100 Subject: [PATCH 4/5] fix: return a tuple from every upload_binary exit and pin the auth header upload_binary returned a bare False on its three failure paths while its caller unpacks two values, so any upload failure surfaced as "cannot unpack non-iterable bool object" and hid the real error. Return the message alongside the flag on all of them. Also pin the Authorization header the SDK produces. An operation's auth settings are applied in order onto the same header, so configuring access_token alongside api_key rewrites every request from the raw key to "Bearer " -- which the gateway rejects on /v3/upload with a 403 from the load balancer, before the request reaches the API. The test asserts a dual-scheme operation still sends the unprefixed key. GET /v2/iam/me remains the one operation declaring bearerAuth alone, so it gets no Authorization header and the tier lookup 401s. That predates the 4.72.2 bump and get_user_tier already treats it as non-fatal; a fix has to scope bearer credentials to that single call rather than the whole client, and is left for a change that can be verified against the API. A test pins the bearer-only declaration so the gap stays visible. --- reai_toolkit/features/upload/upload.py | 6 ++-- tests/unit/config/test_service.py | 39 ++++++++++++++++++++++++++ tests/unit/upload/test_service.py | 30 ++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/reai_toolkit/features/upload/upload.py b/reai_toolkit/features/upload/upload.py index d1622f5..574ddbb 100755 --- a/reai_toolkit/features/upload/upload.py +++ b/reai_toolkit/features/upload/upload.py @@ -60,12 +60,12 @@ def upload_binary(self, bv: BinaryView, options: dict): file = Path(bv.file.filename).read_bytes() if bv.parent_view.length > 10 * 1024 ** 2: log_error(f"RevEng.AI | File size is too large. Please upload a file smaller than 10MB.") - return False + return False, "File size is too large. Please upload a file smaller than 10MB." success, sha_256_hash = self.upload_file(bv.file.filename, revengai.UploadFileType.BINARY) if not success: log_error(f"RevEng.AI | Failed to upload binary {bv.file.filename}.") - return False + return False, sha_256_hash log_info(f"RevEng.AI | Binary {bv.file.filename} uploaded successfully. SHA-256 hash: {sha_256_hash}") @@ -73,7 +73,7 @@ def upload_binary(self, bv: BinaryView, options: dict): success, debug_info_hash = self.upload_file(options["debug_info"], revengai.UploadFileType.DEBUG) if not success: log_error(f"RevEng.AI | Failed to upload debug info {options['debug_info']}.") - return False + return False, debug_info_hash log_info(f"RevEng.AI | Debug info {options['debug_info']} uploaded successfully. SHA-256 hash: {debug_info_hash}") diff --git a/tests/unit/config/test_service.py b/tests/unit/config/test_service.py index 2ab8808..f2b53b8 100644 --- a/tests/unit/config/test_service.py +++ b/tests/unit/config/test_service.py @@ -4,9 +4,20 @@ pytest.importorskip("binaryninja") +import inspect +import re + +import revengai + from reai_toolkit.features.configuration import config as config_mod +def _auth_settings_for(api_cls, serializer): + source = inspect.getsource(getattr(api_cls, serializer)) + block = re.search(r"_auth_settings: List\[str\] = \[(.*?)\]", source, re.S) + return re.findall(r"'([^']+)'", block.group(1)) if block else [] + + class FakeSettings: def __init__(self): self.store = {} @@ -43,6 +54,34 @@ def config(mocker, settings): return config_mod.Config() +def test_api_key_is_sent_unprefixed(config): + """The SDK applies an operation's auth settings in order onto the same + Authorization header, so configuring access_token alongside api_key turns + every request into "Bearer ". The gateway rejects that on /v3/upload + with a 403, so the api-key scheme must stay the only one configured.""" + config.host = "https://example.invalid" + config.api_key = "secret-key" + config.init_api_config() + + assert set(config.api_config.auth_settings()) == {"APIKey"} + + headers = {} + with config.create_api_client() as client: + client.update_params_for_auth( + headers, [], ["APIKey", "bearerAuth"], "/v3/upload", "POST", None + ) + + assert headers["Authorization"] == "secret-key" + + +def test_get_me_declares_bearer_auth_only(config): + """GET /v2/iam/me is the one operation this plugin calls that declares + bearerAuth alone, so it gets no Authorization header and the tier lookup + 401s. get_user_tier treats that as non-fatal. Tracked separately: the fix + has to scope bearer credentials to this call, not the whole client.""" + assert _auth_settings_for(revengai.IAMUsersApi, "_get_me_serialize") == ["bearerAuth"] + + def test_check_auth_true_when_get_config_succeeds(config, mocker): api = mocker.patch.object(config_mod.revengai, "ConfigApi").return_value api.v3_get_config.return_value = MagicMock() diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index 9d525b1..4d3de75 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -79,3 +79,33 @@ def test_get_user_tier_surfaces_failure(uploader, mocker): assert ok is False assert tier is None + + +def test_upload_binary_returns_a_tuple_when_the_upload_fails(mocker): + """upload_binary's caller unpacks two values. Returning a bare False on the + failure paths raised "cannot unpack non-iterable bool object" and masked the + real upload error.""" + uploader = upload_mod.BinaryUploader(MagicMock()) + mocker.patch.object(uploader, "upload_file", return_value=(False, "403 Forbidden")) + bv = MagicMock() + bv.file.filename = "/tmp/sample.bin" + bv.parent_view.length = 1024 + mocker.patch.object(upload_mod.Path, "read_bytes", return_value=b"\x00") + + ok, message = uploader.upload_binary(bv, {"debug_info": None, "tags": [], "is_private": True}) + + assert ok is False + assert message == "403 Forbidden" + + +def test_upload_binary_returns_a_tuple_when_the_file_is_too_large(mocker): + uploader = upload_mod.BinaryUploader(MagicMock()) + bv = MagicMock() + bv.file.filename = "/tmp/big.bin" + bv.parent_view.length = 11 * 1024 ** 2 + mocker.patch.object(upload_mod.Path, "read_bytes", return_value=b"\x00") + + ok, message = uploader.upload_binary(bv, {"debug_info": None, "tags": [], "is_private": True}) + + assert ok is False + assert "too large" in message From 16eec6eaccb90ae7131de5f8f1c82630e2a0f180 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:18:21 +0100 Subject: [PATCH 5/5] fix: keep binary upload on v2 until /v3/upload works in production POST /v3/upload is rejected in production with a 403 served by the load balancer (text/html, Server: awselb/2.0, no trace id) whenever a real multipart body is sent with valid credentials. The same file uploads fine via POST /v2/upload from the same client and the same key, and /v3/upload does not declare 403 at all -- its documented responses are 200, 400, 413, 422 and 500 -- so this is below the API rather than in it. It is not an auth-format problem. Both schemes are globally accepted (APIKey is the raw Authorization value, bearerAuth the prefixed one), the 403 reproduces with the raw key, and an unauthenticated probe of /v2/upload and /v3/upload returns the same 401 from the gateway, so the path is routed and reachable. Revert just this one call to AnalysesCoreApi.upload_file and keep the rest of the v3 migration, which is verified working against production: config, models, binary search, analysis basic info, the status operation and the function maps all round-trip. Restore the v2 pins for upload in the schema guard and raise PINNED to the version pyproject now requires. Revisit once /v3/upload accepts an upload; the migration is a two-line change at that point. --- reai_toolkit/features/upload/upload.py | 6 +++--- tests/headless/test_upload.py | 3 +-- tests/unit/sdk/test_sdk_schemas.py | 15 ++++++++++----- tests/unit/upload/test_service.py | 14 +++++++------- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/reai_toolkit/features/upload/upload.py b/reai_toolkit/features/upload/upload.py index 574ddbb..131ac85 100755 --- a/reai_toolkit/features/upload/upload.py +++ b/reai_toolkit/features/upload/upload.py @@ -44,10 +44,10 @@ def upload_file(self, file_path: str, file_type: revengai.UploadFileType): try: file = Path(file_path).read_bytes() with self.config.create_api_client() as api_client: - api_instance = revengai.BinariesApi(api_client) - api_response = api_instance.v3_upload_file(file, file_type.value, force_overwrite=True) + api_instance = revengai.AnalysesCoreApi(api_client) + api_response = api_instance.upload_file(file_type, file, force_overwrite=True) log_info(f"RevEng.AI | File uploaded successfully.") - return True, api_response.sha_256_hash + return True, api_response.data.sha_256_hash except Exception as e: log_error(f"RevEng.AI | Failed to upload file {file_path}: {str(e)}") return False, str(e) diff --git a/tests/headless/test_upload.py b/tests/headless/test_upload.py index 31c9bf0..d9ff7b5 100644 --- a/tests/headless/test_upload.py +++ b/tests/headless/test_upload.py @@ -11,8 +11,7 @@ def test_upload_binary_collects_real_function_boundaries(bv, mocker): uploader = upload_mod.BinaryUploader(MagicMock()) api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value - binaries = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value - binaries.v3_upload_file.return_value.sha_256_hash = "abcd" + api.upload_file.return_value.data.sha_256_hash = "abcd" api.create_analysis.return_value.data.analysis_id = 1 api.create_analysis.return_value.data.binary_id = 2 mocker.patch.object(upload_mod, "PeriodicChecker") diff --git a/tests/unit/sdk/test_sdk_schemas.py b/tests/unit/sdk/test_sdk_schemas.py index e46500d..1f1eeee 100644 --- a/tests/unit/sdk/test_sdk_schemas.py +++ b/tests/unit/sdk/test_sdk_schemas.py @@ -18,14 +18,15 @@ from revengai.models.task_status import TaskStatus from revengai.models.workflow_progress import WorkflowProgress -PINNED = (4, 4, 0) +PINNED = (4, 72, 2) API_METHODS = { "ConfigApi": ["v3_get_config", "v3_get_models"], - "BinariesApi": ["v3_upload_file", "v3_search_binaries"], + "BinariesApi": ["v3_search_binaries"], "CollectionsApi": ["v3_list_collections"], "AnalysesCoreApi": [ "create_analysis", + "upload_file", "v3_get_analysis_operation", "get_analysis_basic_info_0", "start_analysis_function_matching", @@ -118,12 +119,16 @@ def test_collection_list_item_has_plugin_fields(): def test_upload_file_accepts_plugin_kwargs(): assert {"upload_file_type", "file", "force_overwrite"} <= _params( - revengai.BinariesApi.v3_upload_file + revengai.AnalysesCoreApi.upload_file ) -def test_upload_output_has_no_envelope(): - assert "sha_256_hash" in revengai.UploadOutputBody.model_fields +def test_upload_still_uses_the_v2_envelope(): + """Upload stays on POST /v2/upload. The v3 endpoint is rejected with a 403 + from the load balancer in production -- a status /v3/upload does not even + declare -- so the deprecated-but-working v2 path is kept deliberately.""" + assert "data" in revengai.BaseResponseUploadResponse.model_fields + assert "sha_256_hash" in revengai.UploadResponse.model_fields def test_get_models_output_has_no_envelope(): diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index 4d3de75..e91d759 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -33,8 +33,8 @@ def test_get_models_handles_missing_models(uploader, mocker): def test_upload_file_returns_sha_and_forwards_args(uploader, mocker, tmp_path): - api = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value - api.v3_upload_file.return_value.sha_256_hash = "deadbeef" + api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value + api.upload_file.return_value.data.sha_256_hash = "deadbeef" binary = tmp_path / "bin.elf" binary.write_bytes(b"\x7fELF\x02\x01") @@ -42,15 +42,15 @@ def test_upload_file_returns_sha_and_forwards_args(uploader, mocker, tmp_path): assert ok is True assert sha == "deadbeef" - args, kwargs = api.v3_upload_file.call_args - assert args[0] == b"\x7fELF\x02\x01" - assert args[1] == upload_mod.revengai.UploadFileType.BINARY.value + args, kwargs = api.upload_file.call_args + assert args[0] == upload_mod.revengai.UploadFileType.BINARY + assert args[1] == b"\x7fELF\x02\x01" assert kwargs["force_overwrite"] is True def test_upload_file_surfaces_error(uploader, mocker, tmp_path): - api = mocker.patch.object(upload_mod.revengai, "BinariesApi").return_value - api.v3_upload_file.side_effect = Exception("boom") + api = mocker.patch.object(upload_mod.revengai, "AnalysesCoreApi").return_value + api.upload_file.side_effect = Exception("boom") binary = tmp_path / "bin.elf" binary.write_bytes(b"\x7fELF")