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/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", 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..131ac85 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: @@ -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/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/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/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/unit/config/test_service.py b/tests/unit/config/test_service.py index 08d1617..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,21 +54,82 @@ 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.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..1f1eeee 100644 --- a/tests/unit/sdk/test_sdk_schemas.py +++ b/tests/unit/sdk/test_sdk_schemas.py @@ -3,42 +3,39 @@ 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 -PINNED = (4, 4, 0) +PINNED = (4, 72, 2) API_METHODS = { - "ConfigApi": ["get_config"], - "SearchApi": ["search_binaries"], + "ConfigApi": ["v3_get_config", "v3_get_models"], + "BinariesApi": ["v3_search_binaries"], "CollectionsApi": ["v3_list_collections"], "AnalysesCoreApi": [ - "upload_file", "create_analysis", - "get_analysis_status", - "get_analysis_basic_info", - "get_analysis_function_map", + "upload_file", + "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 +87,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(): @@ -122,6 +123,18 @@ def test_upload_file_accepts_plugin_kwargs(): ) +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(): + 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 +142,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 +251,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..e91d759 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,6 +22,16 @@ 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" @@ -69,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 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]]