From 6e9f191678d09acb04a343f1fd9471d8d21d5611 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:33:02 +0100 Subject: [PATCH 1/2] fix: guard symbol collection against 4.72.2 validation constraints revengai 4.72.2 tightened the pydantic constraints on two models the plugin constructs by hand: FunctionBoundary.mangled_name StrictStr -> min_length=1, max_length=4096 FunctionBoundary.start_address StrictInt -> ge=0 FunctionBoundary.end_address StrictInt -> ge=0 Symbols.base_address StrictInt -> ge=0 Symbols.function_boundaries List -> max_length=200000 collect_symbols_from_ida fed idc.get_func_name() straight into mangled_name. That returns "" for an unnamed function, which now fails min_length=1 and raises ValidationError out of the whole collection pass -- so upload would break outright on any binary containing one. The boundary list was also unbounded, and a negative image base would trip ge=0. Skip boundaries with no name rather than inventing one, truncate over-long names, clamp the base address, and cap the list, warning when either the skip count or the cap is non-zero so the truncation is not silent. Co-Authored-By: Claude Opus 5 (1M context) --- reai_toolkit/app/core/utils.py | 55 +++++++++++++++++++++++---- tests/unit/core/test_utils.py | 69 +++++++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/reai_toolkit/app/core/utils.py b/reai_toolkit/app/core/utils.py index 54facec..6fa7ac6 100644 --- a/reai_toolkit/app/core/utils.py +++ b/reai_toolkit/app/core/utils.py @@ -2,6 +2,8 @@ import json from pathlib import Path +from loguru import logger + from libbs.decompilers.ida.compat import execute_read, execute_write import ida_funcs @@ -14,6 +16,10 @@ from revengai.models.symbols import Symbols +MAX_MANGLED_NAME_LENGTH = 4096 +MAX_FUNCTION_BOUNDARIES = 200000 + + def parse_exception(exception: ApiException) -> BaseResponse | None: """ If the exception body is JSON and matches BaseResponse, return it. @@ -67,10 +73,38 @@ def demangle(mangled_name: str, attr: int = idc.INF_SHORT_DN) -> str: return demangled_name if demangled_name else mangled_name +def build_function_boundary( + mangled_name: str | None, start_address: int, end_address: int +) -> FunctionBoundary | None: + if not mangled_name: + return None + + if start_address < 0 or end_address < 0: + return None + + return FunctionBoundary( + mangled_name=mangled_name[:MAX_MANGLED_NAME_LENGTH], + start_address=start_address, + end_address=end_address, + ) + + +def build_symbols(base_address: int, funcs: list[FunctionBoundary]) -> Symbols: + if len(funcs) > MAX_FUNCTION_BOUNDARIES: + logger.warning( + f"RevEng.AI: Truncating function boundaries from {len(funcs)} to " + f"{MAX_FUNCTION_BOUNDARIES}" + ) + funcs = funcs[:MAX_FUNCTION_BOUNDARIES] + + return Symbols(base_address=max(int(base_address), 0), function_boundaries=funcs) + + @execute_read def collect_symbols_from_ida(inclusive_end: bool = False) -> Symbols | None: base: int = idaapi.get_imagebase() or 0 funcs: list[FunctionBoundary] = [] + skipped: int = 0 plt_section: idaapi.segment_t | None = idaapi.get_segm_by_name(".plt") @@ -86,14 +120,19 @@ def collect_symbols_from_ida(inclusive_end: bool = False) -> Symbols | None: end_ea: int = f.end_ea - 1 if inclusive_end else f.end_ea mangled_name: str = idc.get_func_name(start_ea) - funcs.append( - FunctionBoundary( - mangled_name=mangled_name, - start_address=start_ea, - end_address=end_ea, - ) + boundary: FunctionBoundary | None = build_function_boundary( + mangled_name, start_ea, end_ea ) + if boundary is None: + skipped += 1 + continue + + funcs.append(boundary) - symbols = Symbols(base_address=int(base), function_boundaries=funcs) + if skipped: + logger.warning( + f"RevEng.AI: Skipped {skipped} function boundaries with an empty name " + "or a negative address" + ) - return symbols + return build_symbols(base, funcs) diff --git a/tests/unit/core/test_utils.py b/tests/unit/core/test_utils.py index dd5c37e..e18fc10 100644 --- a/tests/unit/core/test_utils.py +++ b/tests/unit/core/test_utils.py @@ -1,8 +1,26 @@ import hashlib +from unittest.mock import MagicMock + +import pytest from revengai import ApiException, BaseResponse -from reai_toolkit.app.core.utils import parse_exception, sha256_file +from reai_toolkit.app.core import utils +from reai_toolkit.app.core.utils import ( + MAX_FUNCTION_BOUNDARIES, + MAX_MANGLED_NAME_LENGTH, + build_function_boundary, + build_symbols, + parse_exception, + sha256_file, +) + + +@pytest.fixture +def fake_logger(monkeypatch): + logger = MagicMock() + monkeypatch.setattr(utils, "logger", logger) + return logger def test_parse_exception_returns_base_response_for_json_body(): @@ -43,3 +61,52 @@ def test_sha256_file_handles_multichunk(tmp_path): f.write_bytes(payload) assert sha256_file(f, chunk_size=1024) == hashlib.sha256(payload).hexdigest() + + +def test_build_function_boundary_skips_empty_mangled_name(): + assert build_function_boundary("", 0x1000, 0x1010) is None + assert build_function_boundary(None, 0x1000, 0x1010) is None + + +def test_build_function_boundary_truncates_overlong_mangled_name(): + boundary = build_function_boundary("_Z" + "a" * 8192, 0x1000, 0x1010) + + assert boundary is not None + assert len(boundary.mangled_name) == MAX_MANGLED_NAME_LENGTH + assert boundary.mangled_name.startswith("_Za") + + +def test_build_function_boundary_skips_negative_addresses(): + assert build_function_boundary("main", -1, 0x10) is None + assert build_function_boundary("main", 0x10, -1) is None + + +def test_build_function_boundary_keeps_valid_entry(): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + assert boundary.mangled_name == "main" + assert boundary.start_address == 0x1000 + assert boundary.end_address == 0x1010 + + +def test_build_symbols_caps_function_boundaries(fake_logger): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + symbols = build_symbols(0x400000, [boundary] * (MAX_FUNCTION_BOUNDARIES + 25)) + + assert len(symbols.function_boundaries) == MAX_FUNCTION_BOUNDARIES + fake_logger.warning.assert_called_once() + + +def test_build_symbols_keeps_list_under_cap(fake_logger): + boundary = build_function_boundary("main", 0x1000, 0x1010) + + symbols = build_symbols(0x400000, [boundary]) + + assert symbols.base_address == 0x400000 + assert len(symbols.function_boundaries) == 1 + fake_logger.warning.assert_not_called() + + +def test_build_symbols_clamps_negative_base_address(): + assert build_symbols(-1, []).base_address == 0 From d00489384e718c09970f03fff8cecb4e8412cf17 Mon Sep 17 00:00:00 2001 From: Joshua Villalta <93329405+vgoat21@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:33:36 +0100 Subject: [PATCH 2/2] refactor: migrate the deprecated v2 endpoints onto v3 revengai 4.72.2 marks 85 operations deprecated, up from 25. Eight of the nine the plugin called now emit a DeprecationWarning at call time and have a v3 successor; this moves them over. Every v2 operation returned a BaseResponseX envelope with a .data attribute. Every v3 operation returns a flat *OutputBody. So each migration is: swap the method, drop the .data unwrap, remap fields. ConfigApi.get_config -> ConfigApi.v3_get_config AnalysesCoreApi.get_analysis_status -> AnalysesCoreApi.v3_get_analysis_operation AnalysesCoreApi.get_analysis_function_map -> FunctionsCoreApi.v3_get_analysis_func_maps AnalysesCoreApi.list_analyses -> AnalysesCoreApi.v3_list_analyses AnalysesCoreApi.upload_file -> BinariesApi.v3_upload_file SearchApi.search_binaries -> BinariesApi.v3_search_binaries SearchApi.search_collections -> CollectionsApi.v3_list_collections FunctionsAIDecompilationApi.upsert_ai_decompilation_rating -> ..._api.v3_upsert_ai_decompilation_rating Notes on the non-obvious ones: SearchApi is now deprecated in its entirety -- it has zero non-deprecated operations. Binary search moves to GET /v3/binaries and collection search to GET /v3/collections; both swap page/page_size for limit/offset. CollectionListItemBody renames six fields off CollectionSearchResult (scope, last_updated_at, created_at, owned_by, tags, size). Analysis status moves to the long-running-operation endpoint GET /v3/operations/analyses/{analysis_id}, whose CreateMetadata.status carries the same platform status string v2 returned, so the Complete/Error state machine is unchanged. v3_upload_file drops packed_password. That parameter was dead plumbing here: declared and threaded between _upload_file_req and upload_user_file but never supplied by any caller. Archive passwords moved to the sandbox side of the platform (SandboxConfig.archive_password), so it is removed rather than reimplemented. Also handle the types_applied AI-decompilation stream event, new in 4.72.2 as the EventTypesApplied oneOf variant. The hand-rolled SSE parser keys off event names and had no branch for it, so those events were dropped silently. AnalysesCoreApi.create_analysis stays on v2: POST /v2/analyses is deprecated but has no v3 successor in the live v4.73.0 spec. It is now the only deprecated SDK call left in the plugin. The remaining /v2 calls -- IAMUsersApi.get_me and the seven ConversationsApi operations -- are not deprecated and have no /v3 paths in the spec. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/components/dialogs/matching_dialog.py | 8 +- .../services/ai_decomp/ai_decomp_service.py | 8 +- reai_toolkit/app/services/ai_decomp/stream.py | 8 ++ .../analysis_status/analysis_status.py | 20 +++-- .../services/analysis_sync/analysis_sync.py | 17 ++-- .../app/services/auth/auth_service.py | 2 +- .../existing_analyses_service.py | 26 +++--- .../app/services/matching/matching_service.py | 28 +++--- .../app/services/upload/upload_service.py | 30 +++---- tests/unit/ai_decomp/test_sdk_schemas.py | 61 ++++++++++++- tests/unit/ai_decomp/test_service.py | 11 +-- tests/unit/ai_decomp/test_stream.py | 23 +++++ .../unit/analysis_status/test_sdk_schemas.py | 21 +++-- tests/unit/analysis_status/test_service.py | 64 +++++++++++--- tests/unit/analysis_sync/test_sdk_schemas.py | 20 ++--- tests/unit/analysis_sync/test_service.py | 24 +++++ tests/unit/auth/test_endpoint_auth.py | 2 +- tests/unit/auth/test_sdk_schemas.py | 8 +- tests/unit/auth/test_service.py | 10 +-- .../existing_analyses/test_sdk_schemas.py | 32 ++++--- tests/unit/existing_analyses/test_service.py | 37 ++++---- tests/unit/matching/test_matching_service.py | 16 ++-- tests/unit/matching/test_sdk_schemas.py | 4 +- tests/unit/upload/test_sdk_schemas.py | 27 ++++++ tests/unit/upload/test_service.py | 87 ++++++++++++++++--- 25 files changed, 429 insertions(+), 165 deletions(-) create mode 100644 tests/unit/upload/test_sdk_schemas.py diff --git a/reai_toolkit/app/components/dialogs/matching_dialog.py b/reai_toolkit/app/components/dialogs/matching_dialog.py index 1e121d8..34cf5d3 100644 --- a/reai_toolkit/app/components/dialogs/matching_dialog.py +++ b/reai_toolkit/app/components/dialogs/matching_dialog.py @@ -4,7 +4,7 @@ from loguru import logger from revengai.models import ( - BinarySearchResult, + BinarySearchResultBody, CollectionListItemBody, FunctionMatch, MatchedFunction, @@ -178,7 +178,7 @@ def __init__( self._initCollectionsHeader() # ----------------- Binaries state ----------------- - self._selected_binaries: dict[str, BinarySearchResult] = {} + self._selected_binaries: dict[str, BinarySearchResultBody] = {} self._binariesDebounce = QtCore.QTimer(self) self._binariesDebounce.setSingleShot(True) self._binariesDebounce.timeout.connect(self._performBinariesSearch) @@ -557,7 +557,7 @@ def _performBinariesSearch(self): text = getattr(self.ui, "editBinaries", None) query = text.text().strip() if text else "" - response: GenericApiReturn[List[BinarySearchResult]] = ( + response: GenericApiReturn[List[BinarySearchResultBody]] = ( self.matching_service.search_binaries(text_input=query) ) @@ -593,7 +593,7 @@ def _initBinariesHeader(self): else: hdr.setSectionResizeMode(i, QtWidgets.QHeaderView.Stretch) - def _fillBinariesPopup(self, query: str, rows: list[BinarySearchResult]): + def _fillBinariesPopup(self, query: str, rows: list[BinarySearchResultBody]): if not hasattr(self.ui, "binariesPopupView"): return view = self.ui.binariesPopupView diff --git a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py index decd440..f81ceac 100644 --- a/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py +++ b/reai_toolkit/app/services/ai_decomp/ai_decomp_service.py @@ -19,10 +19,8 @@ from revengai.models.summary_data import SummaryData from revengai.models.task_status import TaskStatus from revengai.models.token import Token -from revengai.models.upsert_ai_decomplation_rating_request import ( - UpsertAiDecomplationRatingRequest, -) from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody +from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -239,9 +237,9 @@ def _run_rate_decomp( ) -> None: try: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - FunctionsAIDecompilationApi(api_client).upsert_ai_decompilation_rating( + FunctionsAIDecompilationApi(api_client).v3_upsert_ai_decompilation_rating( function_id=function_id, - upsert_ai_decomplation_rating_request=UpsertAiDecomplationRatingRequest( + upsert_rating_input_body=UpsertRatingInputBody( rating=rating, reason=None ), ) diff --git a/reai_toolkit/app/services/ai_decomp/stream.py b/reai_toolkit/app/services/ai_decomp/stream.py index 1c650d2..1e106c0 100644 --- a/reai_toolkit/app/services/ai_decomp/stream.py +++ b/reai_toolkit/app/services/ai_decomp/stream.py @@ -15,6 +15,7 @@ EVENT_RENAME_APPLIED = "rename_applied" EVENT_SOURCE_DELTA = "source_delta" EVENT_SOURCE_RESET = "source_reset" +EVENT_TYPES_APPLIED = "types_applied" EVENT_TYPES_SUGGESTED = "types_suggested" EVENT_WARNING = "warning" @@ -41,6 +42,7 @@ class DecompEvent: applied: int = 0 types: int = 0 members: int = 0 + skipped: int = 0 def _as_int(value) -> int: @@ -71,6 +73,7 @@ def normalize_event(event_name: Optional[str], payload: dict) -> Optional[Decomp applied=_as_int(payload.get("applied")), types=_as_int(payload.get("types")), members=_as_int(payload.get("members")), + skipped=_as_int(payload.get("skipped")), ) @@ -110,6 +113,8 @@ class StreamState: names_applied: int = 0 types_suggested: int = 0 members_suggested: int = 0 + types_applied: int = 0 + types_skipped: int = 0 def reset_attempt(self, attempt: int) -> None: self.attempt = attempt @@ -139,6 +144,9 @@ def apply(self, event: DecompEvent) -> None: elif event.type == EVENT_TYPES_SUGGESTED: self.types_suggested = event.types self.members_suggested = event.members + elif event.type == EVENT_TYPES_APPLIED: + self.types_applied = event.types + self.types_skipped = event.skipped elif event.type == EVENT_DECOMP_FINISHED: self.decomp_finished = True elif event.type == EVENT_ATTEMPT_FAILED: diff --git a/reai_toolkit/app/services/analysis_status/analysis_status.py b/reai_toolkit/app/services/analysis_status/analysis_status.py index 0c03c83..fcb1e06 100644 --- a/reai_toolkit/app/services/analysis_status/analysis_status.py +++ b/reai_toolkit/app/services/analysis_status/analysis_status.py @@ -5,10 +5,10 @@ from loguru import logger from revengai import ( AnalysesCoreApi, - BaseResponseStatus, Configuration, + CreateMetadata, GetAnalysisLogsOutputBody, - StatusOutput, + OperationCreateMetadataCreateResult, ) from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -37,7 +37,7 @@ def start_polling(self, analysis_id: int, thread_callback: Callable[..., Any]) - args=(analysis_id,), ) - def _api_get_status(self, analysis_id: int) -> StatusOutput | None: + def _api_get_status(self, analysis_id: int) -> CreateMetadata | None: """ Calls the API to get the analysis status. Returns GenericApiReturn with status string on success. @@ -45,12 +45,14 @@ def _api_get_status(self, analysis_id: int) -> StatusOutput | None: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: analyses_client = AnalysesCoreApi(api_client) - analysis_status: BaseResponseStatus = analyses_client.get_analysis_status(analysis_id=analysis_id) - status: StatusOutput | None = analysis_status.data - if status: - self.netstore_service.put_analysis_status(status.analysis_status) + operation: OperationCreateMetadataCreateResult = ( + analyses_client.v3_get_analysis_operation(analysis_id=analysis_id) + ) + metadata: CreateMetadata | None = operation.metadata + if metadata: + self.netstore_service.put_analysis_status(metadata.status) - return analysis_status.data + return metadata def _api_get_logs(self, analysis_id: int) -> GetAnalysisLogsOutputBody | None: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: @@ -83,7 +85,7 @@ def _poll_analysis_status(self, stop_event: threading.Event, analysis_id: int) - for entry in (logs.entries or []): logger.debug(f"RevEng.AI Remote Analysis - {entry.text}") - if get_status_response.data and (status := get_status_response.data.analysis_status): + if get_status_response.data and (status := get_status_response.data.status): logger.info(f"RevEng.AI: Status - {status}") if status == "Complete": self.call_callback( diff --git a/reai_toolkit/app/services/analysis_sync/analysis_sync.py b/reai_toolkit/app/services/analysis_sync/analysis_sync.py index ef50857..82bdfb4 100644 --- a/reai_toolkit/app/services/analysis_sync/analysis_sync.py +++ b/reai_toolkit/app/services/analysis_sync/analysis_sync.py @@ -12,8 +12,9 @@ from revengai import ( AnalysesCoreApi, AnalysisBasicInfoOutputBody, - BaseResponseAnalysisFunctionMapping, Configuration, + FunctionsCoreApi, + GetFunctionMapsOutputBody, ) from reai_toolkit.app.core.netstore_service import SimpleNetStore @@ -59,7 +60,7 @@ def start_syncing(self, func_map: FunctionMapping, callback: Callable[[GenericAp args=(func_map, callback), ) - def get_function_matches(self, callback: Callable[[FunctionMapping], None]) -> None: + def get_function_matches(self, callback: Callable[[GetFunctionMapsOutputBody], None]) -> None: analysis_id: int | None = self.netstore_service.get_analysis_id() if analysis_id is None: return @@ -72,7 +73,7 @@ def get_function_matches(self, callback: Callable[[FunctionMapping], None]) -> N logger.error("failed to retrieve model id") return - response: GenericApiReturn[FunctionMapping] = self.api_request_returning( + response: GenericApiReturn[GetFunctionMapsOutputBody] = self.api_request_returning( fn=lambda: self._fetch_function_map(analysis_id=analysis_id) ) @@ -118,17 +119,15 @@ def _get_current_base_address(self) -> int: def _rebase_program(self, base_address_delta: int) -> None: idaapi.rebase_program(base_address_delta, idaapi.MSF_FIXONCE) - def _fetch_function_map(self, analysis_id: int) -> FunctionMapping: + def _fetch_function_map(self, analysis_id: int) -> GetFunctionMapsOutputBody: """ Fetches the function map for the given analysis ID. """ with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - analyses_client = AnalysesCoreApi(api_client) + functions_client = FunctionsCoreApi(api_client) - function_map: BaseResponseAnalysisFunctionMapping = analyses_client.get_analysis_function_map(analysis_id=analysis_id) - if function_map.data: - func_map: FunctionMapping = function_map.data.function_maps - self.netstore_service.put_function_mapping(func_map) + func_map: GetFunctionMapsOutputBody = functions_client.v3_get_analysis_func_maps(analysis_id=analysis_id) + self.netstore_service.put_function_mapping(func_map) return func_map diff --git a/reai_toolkit/app/services/auth/auth_service.py b/reai_toolkit/app/services/auth/auth_service.py index 071b5ab..22039ee 100644 --- a/reai_toolkit/app/services/auth/auth_service.py +++ b/reai_toolkit/app/services/auth/auth_service.py @@ -85,7 +85,7 @@ def verify(self) -> Tuple[bool, str]: if hasattr(self.sdk_config, "user_agent"): api_client.user_agent = self.sdk_config.user_agent try: - ConfigApi(api_client).get_config() + ConfigApi(api_client).v3_get_config() logger.info("RevEng.AI: User authenticated successfully.") self._is_authed = True self.get_me(force_refresh=True) diff --git a/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py b/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py index 0d38132..cd8fa63 100644 --- a/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py +++ b/reai_toolkit/app/services/existing_analyses/existing_analyses_service.py @@ -2,13 +2,7 @@ from typing import List, Optional from revengai import AnalysesCoreApi, Configuration -from revengai.models import ( - AnalysisRecord, - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) +from revengai.models import AnalysisRecordBody from reai_toolkit.app.core.netstore_service import SimpleNetStore from reai_toolkit.app.core.shared_schema import GenericApiReturn @@ -22,28 +16,28 @@ class ExistingAnalysesService(IThreadService): def __init__(self, netstore_service: SimpleNetStore, sdk_config: Configuration): super().__init__(netstore_service=netstore_service, sdk_config=sdk_config) - def _fetch_analysis_records(self, sha256_hash: str) -> List[AnalysisRecord]: + def _fetch_analysis_records(self, sha256_hash: str) -> List[AnalysisRecordBody]: """ Helper method to fetch analysis records by SHA256 hash. - Returns a list of AnalysisRecord. + Returns a list of AnalysisRecordBody. """ with self.yield_api_client(sdk_config=self.sdk_config) as api_client: analyses_client = AnalysesCoreApi(api_client) - result = analyses_client.list_analyses( - workspace=[Workspace.PERSONAL], - status=[StatusInput.COMPLETE], + result = analyses_client.v3_list_analyses( + analysis_scope=["PRIVATE"], + status=["Complete"], sha256_hash=sha256_hash, - order_by=AppApiRestV2AnalysesEnumsOrderBy.CREATED, - order=Order.DESC, + order_by="created", + order="DESC", ) - return result.data.results + return result.results or [] def fetch_analyses_same_hash( self, file_path: str - ) -> GenericApiReturn[List[AnalysisRecord]]: + ) -> GenericApiReturn[List[AnalysisRecordBody]]: """ Fetches analyses with the same hash as the given file. Returns a list of analysis summaries. diff --git a/reai_toolkit/app/services/matching/matching_service.py b/reai_toolkit/app/services/matching/matching_service.py index e04fd9c..7d065ae 100644 --- a/reai_toolkit/app/services/matching/matching_service.py +++ b/reai_toolkit/app/services/matching/matching_service.py @@ -9,7 +9,8 @@ from loguru import logger from revengai import ( - BinarySearchResult, + BinariesApi, + BinarySearchResultBody, CollectionListItemBody, CollectionsApi, Configuration, @@ -20,7 +21,6 @@ GetMatchesStatusOutputBody, MatchFilters, ProgressMessage, - SearchApi, StartMatchingForFunctionsInputBody, TaskStatus, ) @@ -84,42 +84,42 @@ def search_collections( ) return response - def _search_binaries(self, text_input: str) -> List[BinarySearchResult]: + def _search_binaries(self, text_input: str) -> List[BinarySearchResultBody]: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - search_client = SearchApi(api_client) + binaries_client = BinariesApi(api_client) model_name: str | None = self.netstore_service.get_model_name() current_binary_id: int | None = self.netstore_service.get_binary_id() - return_list: List[BinarySearchResult] = [] + return_list: List[BinarySearchResultBody] = [] - response = search_client.search_binaries( + response = binaries_client.v3_search_binaries( partial_name=text_input, partial_sha256=None, - page_size=5, - page=1, + limit=5, + offset=0, model_name=model_name, exclude_binary_id=current_binary_id, ) - return_list.extend(response.data.results) + return_list.extend(response.results or []) - response = search_client.search_binaries( + response = binaries_client.v3_search_binaries( partial_name=None, partial_sha256=text_input, - page_size=5, - page=1, + limit=5, + offset=0, model_name=model_name, exclude_binary_id=current_binary_id, ) - return_list.extend(response.data.results) + return_list.extend(response.results or []) return return_list def search_binaries( self, text_input: str - ) -> GenericApiReturn[List[BinarySearchResult]]: + ) -> GenericApiReturn[List[BinarySearchResultBody]]: response = self.api_request_returning(lambda: self._search_binaries(text_input)) # If the search fails, return an empty list instead of an error if not response.success: diff --git a/reai_toolkit/app/services/upload/upload_service.py b/reai_toolkit/app/services/upload/upload_service.py index 1c06f35..205c092 100644 --- a/reai_toolkit/app/services/upload/upload_service.py +++ b/reai_toolkit/app/services/upload/upload_service.py @@ -4,7 +4,8 @@ from typing import Optional, Tuple from loguru import logger -from revengai import AnalysesCoreApi, BaseResponseConfigResponse, Configuration, Symbols +from revengai import AnalysesCoreApi, Configuration, GetConfigOutputBody, Symbols +from revengai.api.binaries_api import BinariesApi from revengai.api.config_api import ConfigApi from revengai.models.analysis_config import AnalysisConfig @@ -13,7 +14,7 @@ from revengai.models.analysis_scope import AnalysisScope from revengai.models.tag import Tag from revengai.models.upload_file_type import UploadFileType -from revengai.models.base_response_upload_response import BaseResponseUploadResponse +from revengai.models.upload_output_body import UploadOutputBody from reai_toolkit.app.core.netstore_service import SimpleNetStore from reai_toolkit.app.core.shared_schema import GenericApiReturn @@ -90,7 +91,7 @@ def analyse_file( debug_sha256 = sha256_file(dp) # First, upload the file - upload_response: GenericApiReturn[BaseResponseUploadResponse] = self.upload_user_file( + upload_response: GenericApiReturn[UploadOutputBody] = self.upload_user_file( file_path=file_path, upload_file_type=UploadFileType.BINARY, # must match server UploadFileType force_overwrite=True, @@ -131,9 +132,9 @@ def analyse_file( def _get_max_upload_size(self) -> int: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: config_client: ConfigApi = ConfigApi(api_client) - response: BaseResponseConfigResponse = config_client.get_config() - if response.data: - return response.data.max_file_size_bytes + response: GetConfigOutputBody = config_client.v3_get_config() + if response.max_file_size_bytes: + return response.max_file_size_bytes return MAX_DEFAULT_FILE_SIZE_BYTES @@ -141,15 +142,13 @@ def _upload_file_req( self, upload_file_type: UploadFileType, file: Tuple[str, bytes], - packed_password: Optional[str] = None, force_overwrite: bool = False, - ) -> BaseResponseUploadResponse: + ) -> UploadOutputBody: with self.yield_api_client(sdk_config=self.sdk_config) as api_client: - analyses_client = AnalysesCoreApi(api_client) - return analyses_client.upload_file( - upload_file_type=UploadFileType(upload_file_type), + binaries_client = BinariesApi(api_client) + return binaries_client.v3_upload_file( + upload_file_type=UploadFileType(upload_file_type).value, force_overwrite=force_overwrite, - packed_password=packed_password, file=file, ) @@ -158,9 +157,8 @@ def upload_user_file( file_path: str, *, upload_file_type: UploadFileType, - packed_password: Optional[str] = None, force_overwrite: bool = False, - ) -> GenericApiReturn[BaseResponseUploadResponse]: + ) -> GenericApiReturn[UploadOutputBody]: p = Path(file_path) if not p.is_file(): return GenericApiReturn(success=False, error_message="File does not exist.") @@ -176,9 +174,9 @@ def upload_user_file( max_upload_size_mb: float = max_upload_size_bytes / (1024 * 1024) return GenericApiReturn(success=False, error_message=f"Failed to upload binary due to it exceeding maximum size limit of {max_upload_size_mb}MiB") - response: GenericApiReturn[BaseResponseUploadResponse] = self.api_request_returning( + response: GenericApiReturn[UploadOutputBody] = self.api_request_returning( lambda: self._upload_file_req( - upload_file_type, (p.name, blob), packed_password, force_overwrite + upload_file_type, (p.name, blob), force_overwrite ) ) diff --git a/tests/unit/ai_decomp/test_sdk_schemas.py b/tests/unit/ai_decomp/test_sdk_schemas.py index 2fd51a5..fd36d3f 100644 --- a/tests/unit/ai_decomp/test_sdk_schemas.py +++ b/tests/unit/ai_decomp/test_sdk_schemas.py @@ -5,9 +5,12 @@ import inspect from revengai import FunctionsAIDecompilationApi, FunctionsCoreApi +from revengai.models.ai_decompilation_rating import AiDecompilationRating from revengai.models.comments_data import CommentsData from revengai.models.create_ai_decomp_output_body import CreateAIDecompOutputBody from revengai.models.disassembly_output_body import DisassemblyOutputBody +from revengai.models.event_types_applied import EventTypesApplied +from revengai.models.event_types_suggested import EventTypesSuggested from revengai.models.line_attributions_data import LineAttributionsData from revengai.models.get_tokens_response import GetTokensResponse from revengai.models.inline_comment import InlineComment @@ -18,8 +21,14 @@ from revengai.models.token import Token from revengai.models.upsert_overrides_data import UpsertOverridesData from revengai.models.upsert_overrides_input_body import UpsertOverridesInputBody +from revengai.models.upsert_rating_input_body import UpsertRatingInputBody from revengai.models.workflow_progress import WorkflowProgress +from reai_toolkit.app.services.ai_decomp.stream import ( + EVENT_TYPES_APPLIED, + EVENT_TYPES_SUGGESTED, +) + def test_ai_decomp_api_exposes_the_methods_the_plugin_calls(): for method in ( @@ -34,7 +43,7 @@ def test_ai_decomp_api_exposes_the_methods_the_plugin_calls(): "regenerate_ai_decompilation_inline_comments", "patch_ai_decompilation_inline_comment", "delete_ai_decompilation_inline_comment", - "upsert_ai_decompilation_rating", + "v3_upsert_ai_decompilation_rating", "v3_get_ai_decompilation_tokens", "v3_upsert_ai_decompilation_overrides", "v3_get_ai_decompilation_line_attributions", @@ -79,6 +88,56 @@ def test_v3_token_methods_accept_plugin_kwargs(): assert {"function_id", "upsert_overrides_input_body"} <= set(overrides) +def test_v3_rating_method_accepts_plugin_kwargs(): + rating = inspect.signature( + FunctionsAIDecompilationApi.v3_upsert_ai_decompilation_rating + ).parameters + assert {"function_id", "upsert_rating_input_body"} <= set(rating) + + serialize = inspect.getsource( + FunctionsAIDecompilationApi._v3_upsert_ai_decompilation_rating_serialize + ) + assert "/v3/functions/{function_id}/ai-decompilation/rating" in serialize + + +def test_v3_rating_returns_nothing_so_success_is_the_absence_of_an_error(): + assert ( + inspect.signature( + FunctionsAIDecompilationApi.v3_upsert_ai_decompilation_rating + ).return_annotation + is None + ) + + +def test_upsert_rating_input_body_carries_rating_and_optional_reason(): + assert {"rating", "reason"} <= set(UpsertRatingInputBody.model_fields) + + body = UpsertRatingInputBody.from_json('{"rating": "POSITIVE"}') + assert body.rating == AiDecompilationRating.POSITIVE + assert body.reason is None + + +def test_upsert_rating_input_body_takes_the_rating_enum_the_plugin_holds(): + body = UpsertRatingInputBody(rating=AiDecompilationRating.NEGATIVE, reason=None) + assert body.to_dict() == {"rating": "NEGATIVE"} + + +def test_the_sse_type_events_carry_the_names_the_stream_parser_keys_off(): + suggested = EventTypesSuggested.from_json( + '{"event": "types_suggested", "data": {"attempt": 1, "seq": 4,' + ' "type": "types_suggested", "types": 2, "members": 7}}' + ) + assert suggested.event == EVENT_TYPES_SUGGESTED + assert (suggested.data.types, suggested.data.members) == (2, 7) + + applied = EventTypesApplied.from_json( + '{"event": "types_applied", "data": {"attempt": 1, "seq": 5,' + ' "type": "types_applied", "types": 2, "skipped": 1}}' + ) + assert applied.event == EVENT_TYPES_APPLIED + assert (applied.data.types, applied.data.skipped) == (2, 1) + + def test_workflow_progress_has_expected_fields(): fields = set(WorkflowProgress.model_fields.keys()) assert {"status", "step", "step_index", "steps_total", "messages"} <= fields diff --git a/tests/unit/ai_decomp/test_service.py b/tests/unit/ai_decomp/test_service.py index 44c6b36..c7f7526 100644 --- a/tests/unit/ai_decomp/test_service.py +++ b/tests/unit/ai_decomp/test_service.py @@ -630,7 +630,7 @@ def test_mutation_unknown_function_id_reports_failure(service, sdk, netstore): def test_rate_decomp_calls_upsert_rating(service, sdk): - sdk.upsert_ai_decompilation_rating.return_value = MagicMock() + sdk.v3_upsert_ai_decompilation_rating.return_value = None on_result = MagicMock() service.rate_decomp( @@ -638,16 +638,17 @@ def test_rate_decomp_calls_upsert_rating(service, sdk): ) _wait_mock(on_result) - _, kwargs = sdk.upsert_ai_decompilation_rating.call_args + _, kwargs = sdk.v3_upsert_ai_decompilation_rating.call_args assert kwargs["function_id"] == 42 - body = kwargs["upsert_ai_decomplation_rating_request"] + body = kwargs["upsert_rating_input_body"] assert body.rating == AiDecompilationRating.POSITIVE assert body.reason is None assert on_result.call_args[0][0].success is True + assert on_result.call_args[0][0].data is True def test_rate_decomp_api_error_surfaces(service, sdk): - sdk.upsert_ai_decompilation_rating.side_effect = ApiException(status=422) + sdk.v3_upsert_ai_decompilation_rating.side_effect = ApiException(status=422) on_result = MagicMock() service.rate_decomp( @@ -668,7 +669,7 @@ def test_rate_decomp_unknown_function_id_reports_failure(service, sdk, netstore) on_result.assert_called_once() assert on_result.call_args[0][0].success is False - sdk.upsert_ai_decompilation_rating.assert_not_called() + sdk.v3_upsert_ai_decompilation_rating.assert_not_called() def test_invalidate_clears_all_caches_and_inflight(service): diff --git a/tests/unit/ai_decomp/test_stream.py b/tests/unit/ai_decomp/test_stream.py index 226128f..cb2c1cf 100644 --- a/tests/unit/ai_decomp/test_stream.py +++ b/tests/unit/ai_decomp/test_stream.py @@ -12,6 +12,7 @@ EVENT_RENAME_APPLIED, EVENT_SOURCE_DELTA, EVENT_SOURCE_RESET, + EVENT_TYPES_APPLIED, EVENT_TYPES_SUGGESTED, EVENT_WARNING, DecompEvent, @@ -237,6 +238,28 @@ def test_renames_warnings_and_type_suggestions_are_collected(): assert (state.types_suggested, state.members_suggested) == (2, 7) +def test_types_applied_records_the_applied_and_skipped_counts(): + state = reduce_stream( + [ + _ev(EVENT_TYPES_SUGGESTED, types=3, members=9), + _ev(EVENT_TYPES_APPLIED, types=2, skipped=1), + ] + ) + + assert (state.types_applied, state.types_skipped) == (2, 1) + assert (state.types_suggested, state.members_suggested) == (3, 9) + + +def test_types_applied_frames_survive_the_parser(): + chunks = [_frame(EVENT_TYPES_APPLIED, types=2, skipped=1)] + + events = list(iter_stream_events(chunks)) + + assert [(e.type, e.types, e.skipped) for e in events] == [ + (EVENT_TYPES_APPLIED, 2, 1) + ] + + def test_a_warning_with_no_message_falls_back_to_its_kind(): state = reduce_stream([_ev(EVENT_WARNING, warning_kind="TRUNCATED")]) diff --git a/tests/unit/analysis_status/test_sdk_schemas.py b/tests/unit/analysis_status/test_sdk_schemas.py index c9ef268..273b8b2 100644 --- a/tests/unit/analysis_status/test_sdk_schemas.py +++ b/tests/unit/analysis_status/test_sdk_schemas.py @@ -1,13 +1,15 @@ +import typing + from revengai import ( AnalysisLogEntry, - BaseResponseStatus, + CreateMetadata, GetAnalysisLogsOutputBody, - StatusOutput, + OperationCreateMetadataCreateResult, ) -def test_status_output_fields(): - assert {"analysis_id", "analysis_status"} <= set(StatusOutput.model_fields) +def test_create_metadata_fields(): + assert {"analysis_id", "binary_id", "status"} <= set(CreateMetadata.model_fields) def test_logs_output_carries_entries(): @@ -18,5 +20,12 @@ def test_log_entry_fields(): assert {"text", "level", "timestamp"} <= set(AnalysisLogEntry.model_fields) -def test_base_response_status_envelope(): - assert {"status", "data", "errors"} <= set(BaseResponseStatus.model_fields) +def test_operation_fields(): + assert {"done", "error", "metadata", "name", "response"} <= set( + OperationCreateMetadataCreateResult.model_fields + ) + + +def test_operation_metadata_is_optional_create_metadata(): + annotation = OperationCreateMetadataCreateResult.model_fields["metadata"].annotation + assert set(typing.get_args(annotation)) == {CreateMetadata, type(None)} diff --git a/tests/unit/analysis_status/test_service.py b/tests/unit/analysis_status/test_service.py index 8cbdb23..114a8b0 100644 --- a/tests/unit/analysis_status/test_service.py +++ b/tests/unit/analysis_status/test_service.py @@ -3,8 +3,11 @@ import pytest from revengai import ApiException +from revengai.models.create_metadata import CreateMetadata from revengai.models.get_analysis_logs_output_body import GetAnalysisLogsOutputBody -from revengai.models.status_output import StatusOutput +from revengai.models.operation_create_metadata_create_result import ( + OperationCreateMetadataCreateResult, +) from reai_toolkit.app.services.analysis_status import analysis_status as svc_mod from reai_toolkit.app.services.analysis_status.analysis_status import ( @@ -12,6 +15,7 @@ ) ANALYSIS_ID = 4242 +BINARY_ID = 77 @pytest.fixture(autouse=True) @@ -41,9 +45,21 @@ def sdk(mocker): return api_inst -def _status(value: str): - return MagicMock( - data=StatusOutput.model_construct(analysis_id=ANALYSIS_ID, analysis_status=value) +def _operation(status: str): + return OperationCreateMetadataCreateResult( + done=status in ("Complete", "Error"), + name=f"operations/analyses/{ANALYSIS_ID}", + metadata=CreateMetadata( + analysis_id=ANALYSIS_ID, binary_id=BINARY_ID, status=status + ), + ) + + +def _operation_without_metadata(): + return OperationCreateMetadataCreateResult( + done=False, + name=f"operations/analyses/{ANALYSIS_ID}", + metadata=None, ) @@ -55,7 +71,7 @@ def _poll(service): def test_complete_status_reports_analysis_id(service, sdk, netstore): - sdk.get_analysis_status.return_value = _status("Complete") + sdk.v3_get_analysis_operation.return_value = _operation("Complete") cb = _poll(service) @@ -66,8 +82,16 @@ def test_complete_status_reports_analysis_id(service, sdk, netstore): netstore.put_analysis_status.assert_called_with("Complete") +def test_status_is_read_from_operation_metadata(service, sdk): + sdk.v3_get_analysis_operation.return_value = _operation("Complete") + + _poll(service) + + sdk.v3_get_analysis_operation.assert_called_with(analysis_id=ANALYSIS_ID) + + def test_error_status_reports_failure(service, sdk): - sdk.get_analysis_status.return_value = _status("Error") + sdk.v3_get_analysis_operation.return_value = _operation("Error") cb = _poll(service) @@ -77,7 +101,7 @@ def test_error_status_reports_failure(service, sdk): def test_status_request_failure_surfaces_error(service, sdk): - sdk.get_analysis_status.side_effect = ApiException(status=500, reason="boom") + sdk.v3_get_analysis_operation.side_effect = ApiException(status=500, reason="boom") cb = _poll(service) @@ -86,10 +110,30 @@ def test_status_request_failure_surfaces_error(service, sdk): sdk.v3_get_analysis_logs.assert_not_called() -def test_in_progress_status_keeps_polling_until_complete(service, sdk): - sdk.get_analysis_status.side_effect = [_status("Processing"), _status("Complete")] +def test_in_progress_status_keeps_polling_until_complete(service, sdk, netstore): + sdk.v3_get_analysis_operation.side_effect = [ + _operation("Processing"), + _operation("Complete"), + ] + + cb = _poll(service) + + assert sdk.v3_get_analysis_operation.call_count == 2 + assert cb.call_args[0][0].success is True + assert [call.args[0] for call in netstore.put_analysis_status.call_args_list] == [ + "Processing", + "Complete", + ] + + +def test_missing_metadata_keeps_polling_without_recording_status(service, sdk, netstore): + sdk.v3_get_analysis_operation.side_effect = [ + _operation_without_metadata(), + _operation("Complete"), + ] cb = _poll(service) - assert sdk.get_analysis_status.call_count == 2 + assert sdk.v3_get_analysis_operation.call_count == 2 assert cb.call_args[0][0].success is True + netstore.put_analysis_status.assert_called_once_with("Complete") diff --git a/tests/unit/analysis_sync/test_sdk_schemas.py b/tests/unit/analysis_sync/test_sdk_schemas.py index 8e172b8..33fb460 100644 --- a/tests/unit/analysis_sync/test_sdk_schemas.py +++ b/tests/unit/analysis_sync/test_sdk_schemas.py @@ -1,9 +1,8 @@ from revengai import ( AnalysisBasicInfoOutputBody, - BaseResponseAnalysisFunctionMapping, - FunctionMapping, + FunctionsCoreApi, + GetFunctionMapsOutputBody, ) -from revengai.models.analysis_function_mapping import AnalysisFunctionMapping def test_basic_info_carries_model_and_base_address(): @@ -12,12 +11,13 @@ def test_basic_info_carries_model_and_base_address(): ) -def test_function_mapping_envelope_carries_function_maps(): - assert {"status", "data"} <= set(BaseResponseAnalysisFunctionMapping.model_fields) - assert "function_maps" in AnalysisFunctionMapping.model_fields - - -def test_function_mapping_fields(): +def test_func_maps_output_is_flat(): assert {"function_map", "inverse_function_map", "name_map"} <= set( - FunctionMapping.model_fields + GetFunctionMapsOutputBody.model_fields ) + assert "data" not in GetFunctionMapsOutputBody.model_fields + assert "function_maps" not in GetFunctionMapsOutputBody.model_fields + + +def test_func_maps_lives_on_functions_core_api(): + assert hasattr(FunctionsCoreApi, "v3_get_analysis_func_maps") diff --git a/tests/unit/analysis_sync/test_service.py b/tests/unit/analysis_sync/test_service.py index 35c2f0e..d5134e0 100644 --- a/tests/unit/analysis_sync/test_service.py +++ b/tests/unit/analysis_sync/test_service.py @@ -5,6 +5,7 @@ AnalysisBasicInfoOutputBody, ) from revengai.models.function_mapping import FunctionMapping +from revengai.models.get_function_maps_output_body import GetFunctionMapsOutputBody from reai_toolkit.app.services.analysis_sync import analysis_sync as svc_mod from reai_toolkit.app.services.analysis_sync.analysis_sync import AnalysisSyncService @@ -266,3 +267,26 @@ def test_fetch_model_id_skips_rebase_when_base_address_unknown( service._fetch_model_id(1234) rebase.assert_not_called() + + +@pytest.fixture +def functions_api(mocker): + mocker.patch.object(AnalysisSyncService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "FunctionsCoreApi") + api = MagicMock() + api_class.return_value = api + return api + + +def test_fetch_function_map_reads_v3_func_maps(service, functions_api, netstore): + body = GetFunctionMapsOutputBody.model_construct( + function_map={"1": 0x1000}, + inverse_function_map={"4096": 1}, + name_map={"4096": "fn"}, + ) + functions_api.v3_get_analysis_func_maps.return_value = body + + assert service._fetch_function_map(1234) is body + + functions_api.v3_get_analysis_func_maps.assert_called_once_with(analysis_id=1234) + netstore.put_function_mapping.assert_called_once_with(body) diff --git a/tests/unit/auth/test_endpoint_auth.py b/tests/unit/auth/test_endpoint_auth.py index a12294e..9259846 100644 --- a/tests/unit/auth/test_endpoint_auth.py +++ b/tests/unit/auth/test_endpoint_auth.py @@ -83,7 +83,7 @@ def test_the_call_discovery_actually_finds_the_plugins_endpoints(): calls = _api_calls() assert "get_me" in calls.get("IAMUsersApi", set()) - assert "get_config" in calls.get("ConfigApi", set()) + assert "v3_get_config" in calls.get("ConfigApi", set()) assert sum(len(v) for v in calls.values()) > 40 diff --git a/tests/unit/auth/test_sdk_schemas.py b/tests/unit/auth/test_sdk_schemas.py index e437b64..9beae51 100644 --- a/tests/unit/auth/test_sdk_schemas.py +++ b/tests/unit/auth/test_sdk_schemas.py @@ -1,8 +1,12 @@ -from revengai import ConfigApi, IAMUsersApi, User +from revengai import ConfigApi, GetConfigOutputBody, IAMUsersApi, User def test_auth_verification_method_exists(): - assert callable(getattr(ConfigApi, "get_config", None)) + assert callable(getattr(ConfigApi, "v3_get_config", None)) + + +def test_config_output_body_exposes_max_file_size_bytes(): + assert "max_file_size_bytes" in GetConfigOutputBody.model_fields def test_iam_get_me_method_exists(): diff --git a/tests/unit/auth/test_service.py b/tests/unit/auth/test_service.py index 886f1fc..35c908e 100644 --- a/tests/unit/auth/test_service.py +++ b/tests/unit/auth/test_service.py @@ -67,7 +67,7 @@ def test_get_sdk_config_builds_once_and_mutates_in_place(service): def test_verify_success(service, config_api): - config_api.get_config.return_value = MagicMock() + config_api.v3_get_config.return_value = MagicMock() ok, msg = service.verify() @@ -77,7 +77,7 @@ def test_verify_success(service, config_api): def test_verify_api_exception_with_parsed_errors(service, config_api, mocker): - config_api.get_config.side_effect = ApiException(status=401) + config_api.v3_get_config.side_effect = ApiException(status=401) mocker.patch.object( svc_mod, "parse_exception", @@ -92,7 +92,7 @@ def test_verify_api_exception_with_parsed_errors(service, config_api, mocker): def test_verify_api_exception_without_parsed_body(service, config_api, mocker): - config_api.get_config.side_effect = ApiException(status=500, reason="boom") + config_api.v3_get_config.side_effect = ApiException(status=500, reason="boom") mocker.patch.object(svc_mod, "parse_exception", return_value=None) ok, msg = service.verify() @@ -103,7 +103,7 @@ def test_verify_api_exception_without_parsed_body(service, config_api, mocker): def test_verify_unexpected_exception(service, config_api): - config_api.get_config.side_effect = RuntimeError("network down") + config_api.v3_get_config.side_effect = RuntimeError("network down") ok, msg = service.verify() @@ -163,7 +163,7 @@ def test_is_enthusiast_false_when_user_unavailable(service, iam_api): def test_verify_success_warms_user_cache(service, config_api, iam_api): - config_api.get_config.return_value = MagicMock() + config_api.v3_get_config.return_value = MagicMock() iam_api.get_me.return_value = _user("ENTHUSIAST") ok, _ = service.verify() diff --git a/tests/unit/existing_analyses/test_sdk_schemas.py b/tests/unit/existing_analyses/test_sdk_schemas.py index 1466936..ae2640f 100644 --- a/tests/unit/existing_analyses/test_sdk_schemas.py +++ b/tests/unit/existing_analyses/test_sdk_schemas.py @@ -1,20 +1,24 @@ -from revengai.models import ( - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) -from revengai.models.analysis_record import AnalysisRecord +import inspect +from revengai import AnalysesCoreApi +from revengai.models.analysis_record_body import AnalysisRecordBody +from revengai.models.list_analyses_output_body import ListAnalysesOutputBody -def test_analysis_record_fields(): + +def test_analysis_record_body_fields(): assert {"analysis_id", "status", "base_address", "sha_256_hash"} <= set( - AnalysisRecord.model_fields + AnalysisRecordBody.model_fields + ) + + +def test_list_analyses_output_is_flat(): + assert {"results", "page_size", "next_page_token"} <= set( + ListAnalysesOutputBody.model_fields ) + assert "data" not in ListAnalysesOutputBody.model_fields -def test_list_analyses_enums_have_expected_members(): - assert "PERSONAL" in Workspace.__members__ - assert "COMPLETE" in StatusInput.__members__ - assert "DESC" in Order.__members__ - assert "CREATED" in AppApiRestV2AnalysesEnumsOrderBy.__members__ +def test_v3_list_analyses_accepts_expected_filters(): + params = inspect.signature(AnalysesCoreApi.v3_list_analyses).parameters + assert {"analysis_scope", "status", "sha256_hash", "order_by", "order"} <= set(params) + assert "workspace" not in params diff --git a/tests/unit/existing_analyses/test_service.py b/tests/unit/existing_analyses/test_service.py index 3e4c4f4..7bc6873 100644 --- a/tests/unit/existing_analyses/test_service.py +++ b/tests/unit/existing_analyses/test_service.py @@ -2,13 +2,7 @@ import pytest from revengai import ApiException -from revengai.models import ( - AppApiRestV2AnalysesEnumsOrderBy, - Order, - StatusInput, - Workspace, -) -from revengai.models.analysis_record import AnalysisRecord +from revengai.models.analysis_record_body import AnalysisRecordBody from reai_toolkit.app.services.existing_analyses import ( existing_analyses_service as svc_mod, @@ -33,28 +27,39 @@ def sdk(mocker): return api_inst -def _record(analysis_id: int) -> AnalysisRecord: - return AnalysisRecord.model_construct(analysis_id=analysis_id, status="Complete") +def _record(analysis_id: int) -> AnalysisRecordBody: + return AnalysisRecordBody.model_construct( + analysis_id=analysis_id, status="Complete" + ) def test_returns_records_and_passes_expected_filters(service, sdk): records = [_record(1), _record(2)] - sdk.list_analyses.return_value = MagicMock(data=MagicMock(results=records)) + sdk.v3_list_analyses.return_value = MagicMock(results=records) result = service.fetch_analyses_same_hash("/path/to/binary.elf") assert result.success is True assert result.data == records - kwargs = sdk.list_analyses.call_args.kwargs + kwargs = sdk.v3_list_analyses.call_args.kwargs assert kwargs["sha256_hash"] == "deadbeef" - assert kwargs["workspace"] == [Workspace.PERSONAL] - assert kwargs["status"] == [StatusInput.COMPLETE] - assert kwargs["order_by"] == AppApiRestV2AnalysesEnumsOrderBy.CREATED - assert kwargs["order"] == Order.DESC + assert kwargs["analysis_scope"] == ["PRIVATE"] + assert kwargs["status"] == ["Complete"] + assert kwargs["order_by"] == "created" + assert kwargs["order"] == "DESC" + + +def test_empty_page_returns_empty_list(service, sdk): + sdk.v3_list_analyses.return_value = MagicMock(results=None) + + result = service.fetch_analyses_same_hash("/path/to/binary.elf") + + assert result.success is True + assert result.data == [] def test_api_failure_is_passed_through(service, sdk): - sdk.list_analyses.side_effect = ApiException(status=500, reason="boom") + sdk.v3_list_analyses.side_effect = ApiException(status=500, reason="boom") result = service.fetch_analyses_same_hash("/path/to/binary.elf") diff --git a/tests/unit/matching/test_matching_service.py b/tests/unit/matching/test_matching_service.py index d5c88db..a352d94 100644 --- a/tests/unit/matching/test_matching_service.py +++ b/tests/unit/matching/test_matching_service.py @@ -37,9 +37,9 @@ def service(netstore): @pytest.fixture -def search_api(mocker): +def binaries_api(mocker): mocker.patch.object(MatchingService, "yield_api_client") - api_class = mocker.patch.object(svc_mod, "SearchApi") + api_class = mocker.patch.object(svc_mod, "BinariesApi") api_inst = MagicMock() api_class.return_value = api_inst return api_inst @@ -109,19 +109,19 @@ def test_search_collections_failure_returns_empty_success(service, collections_a assert out.data == [] -def test_search_binaries_queries_name_and_sha_excluding_current(service, search_api): - search_api.search_binaries.return_value = MagicMock( - data=MagicMock(results=[MagicMock()]) - ) +def test_search_binaries_queries_name_and_sha_excluding_current(service, binaries_api): + binaries_api.v3_search_binaries.return_value = MagicMock(results=[MagicMock()]) out = service.search_binaries("abc") assert out.success is True assert len(out.data) == 2 # one per call (name + sha) - assert search_api.search_binaries.call_count == 2 - for call in search_api.search_binaries.call_args_list: + assert binaries_api.v3_search_binaries.call_count == 2 + for call in binaries_api.v3_search_binaries.call_args_list: assert call.kwargs["exclude_binary_id"] == 7 assert call.kwargs["model_name"] == "binnet-0.1" + assert call.kwargs["limit"] == 5 + assert call.kwargs["offset"] == 0 def test_perform_matching_emits_start_batch_summary_and_filters_results(service, core_api): diff --git a/tests/unit/matching/test_sdk_schemas.py b/tests/unit/matching/test_sdk_schemas.py index ba929b2..07aa5b9 100644 --- a/tests/unit/matching/test_sdk_schemas.py +++ b/tests/unit/matching/test_sdk_schemas.py @@ -1,5 +1,5 @@ from revengai import ( - BinarySearchResult, + BinarySearchResultBody, CollectionListItemBody, FunctionMapping, FunctionMatch, @@ -61,7 +61,7 @@ def test_matched_function_fields(): def test_search_result_fields(): assert {"binary_id", "binary_name", "sha_256_hash"} <= set( - BinarySearchResult.model_fields + BinarySearchResultBody.model_fields ) assert {"collection_id", "collection_name"} <= set( CollectionListItemBody.model_fields diff --git a/tests/unit/upload/test_sdk_schemas.py b/tests/unit/upload/test_sdk_schemas.py new file mode 100644 index 0000000..0fc995a --- /dev/null +++ b/tests/unit/upload/test_sdk_schemas.py @@ -0,0 +1,27 @@ +import inspect + +from revengai import BinariesApi, UploadOutputBody + + +def test_upload_output_body_is_flat(): + assert { + "sha_256_hash", + "file_type", + "filename", + "mime", + "is_archive", + "can_sandbox", + "can_extract", + "analysis_requirements", + } <= set(UploadOutputBody.model_fields) + assert "data" not in UploadOutputBody.model_fields + + +def test_upload_lives_on_binaries_api(): + assert hasattr(BinariesApi, "v3_upload_file") + + +def test_v3_upload_file_has_no_packed_password(): + params = inspect.signature(BinariesApi.v3_upload_file).parameters + assert {"file", "upload_file_type", "force_overwrite"} <= set(params) + assert "packed_password" not in params diff --git a/tests/unit/upload/test_service.py b/tests/unit/upload/test_service.py index 7522a93..110f781 100644 --- a/tests/unit/upload/test_service.py +++ b/tests/unit/upload/test_service.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock import pytest +from revengai import GetConfigOutputBody, UploadOutputBody from revengai.models.function_boundary import FunctionBoundary from revengai.models.symbols import Symbols from revengai.models.tag import Tag @@ -20,7 +21,25 @@ def sdk(mocker): mocker.patch.object(UploadService, "yield_api_client") api_class = mocker.patch.object(svc_mod, "AnalysesCoreApi") api_inst = MagicMock() - api_inst.upload_file.return_value = "parsed-upload-response" + api_class.return_value = api_inst + return api_inst + + +@pytest.fixture +def binaries(mocker): + mocker.patch.object(UploadService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "BinariesApi") + api_inst = MagicMock() + api_inst.v3_upload_file.return_value = "parsed-upload-response" + api_class.return_value = api_inst + return api_inst + + +@pytest.fixture +def config(mocker): + mocker.patch.object(UploadService, "yield_api_client") + api_class = mocker.patch.object(svc_mod, "ConfigApi") + api_inst = MagicMock() api_class.return_value = api_inst return api_inst @@ -35,39 +54,66 @@ def symbols(): ) -def test_upload_file_passes_tuple_to_sdk(service, sdk): +def test_upload_file_passes_tuple_to_v3_sdk(service, binaries): result = service._upload_file_req( upload_file_type=UploadFileType.BINARY, file=("binary.elf", b"\x7fELF\x02\x01"), force_overwrite=True, ) - sdk.upload_file.assert_called_once_with( - upload_file_type=UploadFileType.BINARY, + binaries.v3_upload_file.assert_called_once_with( + upload_file_type="BINARY", force_overwrite=True, - packed_password=None, file=("binary.elf", b"\x7fELF\x02\x01"), ) assert result == "parsed-upload-response" -def test_upload_file_forwards_packed_password(service, sdk): +def test_upload_file_sends_plain_string_file_type(service, binaries): service._upload_file_req( - upload_file_type=UploadFileType.BINARY, + upload_file_type=UploadFileType.DEBUG, file=("x", b""), - packed_password="hunter2", ) - assert sdk.upload_file.call_args.kwargs["packed_password"] == "hunter2" + sent = binaries.v3_upload_file.call_args.kwargs["upload_file_type"] + assert sent == "DEBUG" + assert type(sent) is str -def test_upload_file_defaults_force_overwrite_false(service, sdk): +def test_upload_file_defaults_force_overwrite_false(service, binaries): service._upload_file_req( upload_file_type=UploadFileType.BINARY, file=("x", b""), ) - assert sdk.upload_file.call_args.kwargs["force_overwrite"] is False + assert binaries.v3_upload_file.call_args.kwargs["force_overwrite"] is False + + +def test_upload_user_file_returns_flat_v3_body(service, binaries, mocker, tmp_path): + mocker.patch.object(UploadService, "_get_max_upload_size", return_value=1024) + binaries.v3_upload_file.return_value = UploadOutputBody( + analysis_requirements=[], + can_extract=False, + can_sandbox=False, + file_type="BINARY", + filename="binary.elf", + is_archive=False, + mime="application/x-executable", + sha_256_hash="deadbeef", + ) + target = tmp_path / "binary.elf" + target.write_bytes(b"\x7fELF") + + response = service.upload_user_file( + str(target), + upload_file_type=UploadFileType.BINARY, + force_overwrite=True, + ) + + assert response.success + assert response.data.sha_256_hash == "deadbeef" + assert response.data.file_type == "BINARY" + assert not hasattr(response.data, "data") @pytest.mark.parametrize( @@ -113,3 +159,22 @@ def test_analyse_defaults_no_cache_false(service, sdk, symbols): request = sdk.create_analysis.call_args.kwargs["analysis_create_request"] assert request.analysis_config.no_cache is False + + +def test_max_upload_size_reads_flat_v3_config(service, config): + config.v3_get_config.return_value = GetConfigOutputBody( + ai_decompiler_unsupported_languages=[], + max_file_size_bytes=42 * 1024 * 1024, + ) + + assert service._get_max_upload_size() == 42 * 1024 * 1024 + config.v3_get_config.assert_called_once_with() + + +def test_max_upload_size_falls_back_to_default(service, config): + config.v3_get_config.return_value = GetConfigOutputBody( + ai_decompiler_unsupported_languages=[], + max_file_size_bytes=0, + ) + + assert service._get_max_upload_size() == svc_mod.MAX_DEFAULT_FILE_SIZE_BYTES