diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index 7f718848af..68371add56 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -2,7 +2,7 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA, and build agent environments (AgentEnv bundles) of web applications from a live instance or a capture. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "59" + version: "60" --- # REA @@ -121,6 +121,15 @@ explicit path or endpoint and do not need it. cleanup effects, and do not require a preliminary static session unless their own contract says so. - User-owned browser page already open: `list_browser_targets`. +- Passive traffic capture of a SPA: the default scope is the page's own origin, + and data APIs usually live on another origin. Discover API origins from + absolute URLs in loaded bundles or script text via JavaScript analysis tools + (another passive capture without those origins still scopes to the page + origin, and `completeness.excluded` counts lack URLs). Pass both the page + origin and those API origins in `allowed_origins` — the list replaces the + default scope — and read `completeness.excluded` before reporting that an + API is absent. For long captures with body shapes, use + `export_evidence_bundle` instead of expecting an inline result. - Browser action the agent must perform (click, type, navigate): `capture_browser_scenario` with declared actions. See [runtime observation](references/runtime-observation.md) for connect/launch diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 6dabb4ad78..17e743bd59 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -718,6 +718,13 @@ cache one decoded block and support retained filtered/paged views. No decryption or SerializedFile object decoding is provided. See [UnityFS](https://github.com/morluto/rea/blob/main/docs/unityfs.md). +WeChat mini-program packages (`.wxapkg` files with `0xBE`/`0xED` marks) list +members with declared names and absolute carrier offsets; member bytes are +stored verbatim. `V1MMWX` PC-client packages report `encrypted` directory +status with unknown coverage and are refused on extraction; no decryption is +attempted. See +[wxapkg](https://github.com/morluto/rea/blob/main/docs/wxapkg.md). + U-Boot legacy uImages use the same inspection/extraction workflows. Prefer a retained summary and selected inventory views for header facts or component IDs. Header addresses/CPU/OS are declarations; payloads remain as stored. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 91063384cc..5420b790ad 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -106,7 +106,8 @@ limits, security/privacy/containment implications, and meaningful proof gaps. -- ## Author readiness - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55221d1261..744cb9361f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,8 @@ Before requesting maintainer review, the human owner must: passing. Follow the [verification requirements](#development-feedback-and-pr-verification) for required checks and real-provider coverage. - Complete the **Author readiness** declaration in the PR template, explicitly - stating that the PR is ready for initial review. + stating that the PR is ready for initial review, unless the PR author is + exempt from the declaration as listed below. Keep the PR description concise and current. Explain the problem, observable outcome, approach, and useful evidence; link long logs or put them in optional @@ -114,9 +115,12 @@ validation current as work changes. AI agents must leave human declarations to the human owner and must not attest to a human review on their behalf. The `PR readiness` check reports pending for drafts and requires a named human -owner and all three template declarations for non-drafts. It checks that the -declaration is filled, not whether the code or evidence is correct. Existing PRs -can copy the **Author readiness** section from the current template. +owner and all three template declarations for non-drafts. PRs authored by +@morluto, @kaoru0822-kitauji, @N0zoM1z0, or @gensokyo-dev are exempt from +the declaration; their human ownership, review, and evidence responsibilities +above still apply. The check identifies authors by GitHub account ID. It checks +that the declaration is filled, not whether the code or evidence is correct. +Existing PRs can copy the **Author readiness** section from the current template. Open PRs must have distinct head commits because GitHub shares commit statuses. Maintainers enable merge enforcement after the workflow is merged: run diff --git a/README.md b/README.md index 9d3df46e07..1784ae4143 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** +Join the REA community on Discord + [Quick start](#quick-start) · [How REA works](#how-rea-works) · [What you can analyze](#what-you-can-analyze) · [Showcases](#showcases) · [Build RL environments](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) npx rea-agents setup diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e58c4d94f2..98d4af57fe 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -60,7 +60,7 @@ pin inspection sources; they are not included in the npm package. | ghidra-nativeaot | [MIT](https://github.com/Washi1337/ghidra-nativeaot/blob/effeb734fc570c32650f88b159608979dc7b423e/LICENSE.md) | Pinned inspection source; derived parser attribution above. | | JADX | [Apache-2.0](https://github.com/skylot/jadx/blob/v1.5.6/LICENSE) | The audited jadx-headless-mcp source uses JADX 1.5.6 for Android decompilation; the engine JAR is separately supplied. | | jadx-headless-mcp | [Apache-2.0](https://github.com/1013503897/jadx-headless-mcp/blob/5844800a486d2248fa949b74c9896285f5b54de2/LICENSE) | v0.7.1; pinned submodule and user-supplied JAR. | -| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.988/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.988/NOTICE) | Bring your own `kuna`; REA verified 1.988 and never downloads, installs or bundles it. REA invokes it as a separate process. | +| Kuna | [Apache-2.0](https://github.com/Noelo-Lab/kuna/blob/v1.990/LICENSE), with its [NOTICE](https://github.com/Noelo-Lab/kuna/blob/v1.990/NOTICE) | Bring your own `kuna`; REA verified 1.990 and never downloads, installs or bundles it. REA invokes it as a separate process. | | Apktool | [Apache-2.0](https://github.com/iBotPeaches/Apktool/blob/master/LICENSE.md) | User-supplied Android resource decoder. | | Binwalk | [MIT](https://github.com/ReFirmLabs/binwalk/blob/4fdab3d464d97b68e0af9088df3f9e2e1545b21c/LICENSE) | v3.1.0; pinned submodule and user-supplied firmware engine. | | Unblob | [MIT](https://github.com/onekey-sec/unblob/blob/1fcc7a0a584a70a96c31f5a276c20944d199a089/LICENSE) | 26.6.4; pinned submodule and user-supplied firmware engine. | diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index eb26f63d84..05cbb01781 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -179,6 +179,8 @@ public final class ReaGhidraBridge extends HeadlessScript { private List procedureInventory; private List stringInventory; private DecompInterface decompiler; + // A refused Program may only report its admission failure to the client. + private String identityAdmissionFailure; /** Per-function native decompiler limit from the session; 0 leaves it unbounded. */ private int decompileTimeoutSeconds; // DecompileProcess converts seconds to an int millisecond timer delay. @@ -224,14 +226,17 @@ public final class ReaGhidraBridge extends HeadlessScript { throw new IllegalStateException("Ghidra provider version does not match the session"); } String importedSha256 = currentProgram.getExecutableSHA256(); - if (importedSha256 == null || - !constantTimeEquals( - importedSha256.toLowerCase(Locale.ROOT), - descriptor.targetSha256 - )) { - throw new IllegalStateException( - "Ghidra imported-byte digest does not match the admitted target" - ); + if (importedSha256 == null || !importedSha256.matches("(?i)[0-9a-f]{64}")) { + identityAdmissionFailure = "import_identity_unavailable"; + } + else if (!constantTimeEquals(importedSha256.toLowerCase(Locale.ROOT), + descriptor.targetSha256)) { + identityAdmissionFailure = "import_identity_mismatch"; + } + if (identityAdmissionFailure != null) { + end(false); + serve(descriptor); + return; } admittedTargetSha256 = descriptor.targetSha256; // GhidraScript wraps run() in a transaction. End it before serving so @@ -405,6 +410,14 @@ public final class ReaGhidraBridge extends HeadlessScript { writeSuccess(writer, request.id, result); return; } + if (identityAdmissionFailure != null) { + // Keep the producer alive until the client has consumed the + // refusal and closed or sent shutdown. Every other request + // remains refused, including further startup attempts. + writeFailure(writer, request.id, identityAdmissionFailure, + "Ghidra imported identity admission failed"); + continue; + } decompileTimeoutSeconds = request.decompileTimeoutSeconds() == null ? descriptor.decompileTimeoutSeconds() : request.decompileTimeoutSeconds(); writeSuccess(writer, request.id, handleRequest(request, descriptor)); @@ -771,6 +784,8 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private record ByteSearchBlock(MemoryBlock block, Address low, Address high) {} + private JsonObject searchMemoryBytes(JsonObject params, SessionDescriptor descriptor) throws Exception { // mask, start, end, and cursor are omitted when absent; JSON encoding drops those keys. Set keys = params.keySet(); @@ -831,16 +846,13 @@ public final class ReaGhidraBridge extends HeadlessScript { JsonArray blocks = new JsonArray(); JsonArray skipped = new JsonArray(); JsonArray notLoaded = new JsonArray(); - long covered = 0; - String next = null; + List eligible = new ArrayList<>(); for (MemoryBlock block : ordered) { monitor.checkCancelled(); Address low = block.getStart(); Address high = block.getEnd(); Address bound = start == null ? end : start; if (bound != null && !bound.getAddressSpace().equals(low.getAddressSpace())) continue; - // Without bounds, search loaded memory only. Non-loaded file parts - // (such as .symtab) and overlays stay reachable through explicit bounds. if (bound == null && (!block.isLoaded() || block.isOverlay())) { notLoaded.add(byteSearchCoverage(block, low, high)); continue; @@ -852,40 +864,66 @@ public final class ReaGhidraBridge extends HeadlessScript { skipped.add(byteSearchCoverage(block, low, high)); continue; } - if (next != null) continue; - if (resume != null && low.compareTo(resume) < 0) low = resume; - if (low.compareTo(high) > 0 || !low.getAddressSpace().equals(high.getAddressSpace())) continue; - Address at = low; - Address examinedEnd = high; - while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) { + eligible.add(new ByteSearchBlock(block, low, high)); + } + long covered = 0; + String next = null; + for (int first = 0; first < eligible.size() && next == null;) { + monitor.checkCancelled(); + int last = first; + while (last + 1 < eligible.size()) { monitor.checkCancelled(); - // Ghidra's end address bounds candidate starts, not the final matched byte. - Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor); - monitor.checkCancelled(); - if (found == null || high.subtract(found) < bytes.length - 1L) break; - if (matches.size() == limit) { - next = binding + ":" + canonicalAddress(found); - examinedEnd = found.addNoWrap(bytes.length - 1L); - break; - } - JsonObject match = new JsonObject(); - match.addProperty("address", canonicalAddress(found)); - match.addProperty("segment", block.getName()); - Long offset = targetFileOffset(block, found).offset(); - match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset)); - Function function = currentProgram.getFunctionManager().getFunctionContaining(found); - JsonObject procedure = new JsonObject(); - if (function != null) { - procedure.addProperty("address", canonicalAddress(function.getEntryPoint())); - procedure.addProperty("name", function.getName(true)); - } - match.add("procedure", function == null ? JsonNull.INSTANCE : procedure); - matches.add(match); - at = found.next(); + ByteSearchBlock previous = eligible.get(last); + ByteSearchBlock following = eligible.get(last + 1); + if (!previous.high().getAddressSpace().equals(following.low().getAddressSpace()) || + previous.high().next() == null || !previous.high().next().equals(following.low())) break; + last++; } - JsonObject coverage = byteSearchCoverage(block, low, examinedEnd); - covered = Math.addExact(covered, coverage.get("bytes").getAsLong()); - blocks.add(coverage); + Address low = eligible.get(first).low(); + Address high = eligible.get(last).high(); + if (resume != null && low.compareTo(resume) < 0) low = resume; + if (low.compareTo(high) <= 0 && low.getAddressSpace().equals(high.getAddressSpace())) { + Address at = low; + Address examinedEnd = high; + while (at != null && at.compareTo(high) <= 0 && high.subtract(at) >= bytes.length - 1L) { + monitor.checkCancelled(); + // findBytes bounds candidate starts. The entire candidate remains in this eligible run. + Address found = currentProgram.getMemory().findBytes(at, high.subtract(bytes.length - 1L), bytes, masks, true, monitor); + monitor.checkCancelled(); + if (found == null || high.subtract(found) < bytes.length - 1L) break; + if (matches.size() == limit) { + next = binding + ":" + canonicalAddress(found); + examinedEnd = found.addNoWrap(bytes.length - 1L); + break; + } + MemoryBlock matchBlock = currentProgram.getMemory().getBlock(found); + JsonObject match = new JsonObject(); + match.addProperty("address", canonicalAddress(found)); + match.addProperty("segment", matchBlock.getName()); + Long offset = targetFileOffset(matchBlock, found).offset(); + match.add("file_offset", offset == null ? JsonNull.INSTANCE : GSON.toJsonTree(offset)); + Function function = currentProgram.getFunctionManager().getFunctionContaining(found); + JsonObject procedure = new JsonObject(); + if (function != null) { + procedure.addProperty("address", canonicalAddress(function.getEntryPoint())); + procedure.addProperty("name", function.getName(true)); + } + match.add("procedure", function == null ? JsonNull.INSTANCE : procedure); + matches.add(match); + at = found.next(); + } + for (int index = first; index <= last; index++) { + monitor.checkCancelled(); + ByteSearchBlock item = eligible.get(index); + Address coveredLow = item.low().compareTo(low) < 0 ? low : item.low(); + Address coveredHigh = item.high().compareTo(examinedEnd) > 0 ? examinedEnd : item.high(); + if (coveredLow.compareTo(coveredHigh) > 0) continue; + JsonObject coverage = byteSearchCoverage(item.block(), coveredLow, coveredHigh); + covered = Math.addExact(covered, coverage.get("bytes").getAsLong()); + blocks.add(coverage); + } + } + first = last + 1; } JsonObject searched = new JsonObject(); searched.add("blocks", blocks); @@ -1010,7 +1048,7 @@ public final class ReaGhidraBridge extends HeadlessScript { } catch (RequestFailure failure) { // Existing names/addresses win. Only an unresolved numeric input // can fall back to source coordinates; ambiguity never falls back. - if (!failure.code.equals("not_found") || !selector.matches("0x[0-9a-fA-F]+")) + if (!failure.code.equals("procedure_not_found") || !selector.matches("0x[0-9a-fA-F]+")) throw failure; } } @@ -1037,17 +1075,17 @@ public final class ReaGhidraBridge extends HeadlessScript { } List
candidates = new ArrayList<>(mappedAddresses); if (candidates.size() > 1) - throw new RequestFailure("ambiguous", "File offset " + coordinate + + throw new RequestFailure("file_offset_ambiguous", "File offset " + coordinate + " has multiple loaded mappings: " + candidates.stream().map(ReaGhidraBridge::canonicalAddress).toList() + "; select an explicit analysis address"); if (candidates.isEmpty()) - throw new RequestFailure("not_found", "File offset " + coordinate + + throw new RequestFailure("file_offset_unmapped", "File offset " + coordinate + " has no loaded source mapping for the admitted target file; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) + ". Inspect mappings with rea inspect-native-load-image --provider ghidra --json or MCP inspect_native_load_image."); Address mapped = candidates.get(0); Function function = functionAtOrContaining(mapped); if (function == null) - throw new RequestFailure("not_found", "File offset " + coordinate + + throw new RequestFailure("procedure_not_found", "File offset " + coordinate + " maps to analysis address " + canonicalAddress(mapped) + ", but no procedure starts at or contains that address in the current Ghidra analysis. " + "Choose an observed procedure with CLI rea search . --kind procedures --mode regex --provider ghidra --json or MCP list_procedures."); @@ -1250,6 +1288,12 @@ public final class ReaGhidraBridge extends HeadlessScript { if (component.getDataType() instanceof ghidra.program.model.data.BitFieldDataType bitfield) { field.addProperty("bit_size", bitfield.getBitSize()); field.addProperty("bit_offset", bitfield.getBitOffset()); + JsonObject bitField = new JsonObject(); + bitField.addProperty("base_type_id", bitfield.getBaseDataType().getPathName()); + bitField.addProperty("declared_bit_size", bitfield.getDeclaredBitSize()); + bitField.addProperty("component_type_lookup", typeLookupResult(bitfield)); + bitField.addProperty("base_type_lookup", typeLookupResult(bitfield.getBaseDataType())); + field.add("bit_field", bitField); } field.addProperty("status", "observed"); field.addProperty("flexible_tail", "unknown"); @@ -1280,7 +1324,7 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("members", members); result.addProperty("total_fields", totalFields); result.addProperty("truncated", totalFields > 20_000); - result.add("limitations", GSON.toJsonTree(List.of("Layouts are Ghidra data-type database observations; original debug/source authority and flexible-tail semantics are not inferred. Nested and recursive types retain category-path identities for separate inspection.", "At most 20000 fields or enum members are serialized per type to bound provider output. total_fields and truncated report omissions. Enum values preserve Ghidra's signed long representation."))); + result.add("limitations", GSON.toJsonTree(List.of("Layouts are Ghidra data-type database observations; original debug/source authority and flexible-tail semantics are not inferred. Component type paths do not necessarily identify separately registered types; bit-field lookups report whether the manager resolves equivalent types.", "At most 20000 fields or enum members are serialized per type to bound provider output. total_fields and truncated report omissions. Enum values preserve Ghidra's signed long representation."))); if (type != null) { JsonObject metadata = analysisExtensions.inspectDataType(currentProgram, type, address, monitor); if (metadata != null) result.add("metadata_recovery", metadata); @@ -1288,6 +1332,11 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private String typeLookupResult(DataType type) { + DataType resolved = currentProgram.getDataTypeManager().getDataType(type.getPathName()); + return resolved == null ? "not-found" : resolved.isEquivalent(type) ? "equivalent" : "different"; + } + private JsonObject instructionReference(Reference reference) { JsonObject item = new JsonObject(); JsonObject kind = referenceKind(reference); @@ -2458,7 +2507,7 @@ public final class ReaGhidraBridge extends HeadlessScript { if (matchProcedureNames(entry, selector).matched()) matches.add(entry.function); } if (matches.size() > 1) - throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value + + throw new RequestFailure("procedure_ambiguous", "Ghidra procedure name is ambiguous: " + value + "; select an exact entry address from " + matches.stream() .map(function -> canonicalAddress(function.getEntryPoint())).toList()); if (matches.size() == 1) return matches.get(0); @@ -2466,11 +2515,11 @@ public final class ReaGhidraBridge extends HeadlessScript { // Retain legacy bare-hex address input only when no symbol matches. Address address = explicitAddress ? parseReaAddress(value) : tryParseAddress(value); if (address == null) - throw new RequestFailure("not_found", "Unknown Ghidra procedure name or address: " + value); + throw new RequestFailure("procedure_not_found", "Unknown Ghidra procedure name or address: " + value); Function function = functionAtOrContaining(address); if (function == null) // Other procedure operations retain their analysis-address semantics. - throw new RequestFailure("not_found", "No procedure exists at the requested address: " + value + + throw new RequestFailure("procedure_not_found", "No procedure exists at the requested address: " + value + "; Ghidra image base: " + canonicalAddress(currentProgram.getImageBase()) + ". Choose an observed analysis address. CLI: rea search . --kind procedures --mode regex --provider ghidra --json; " + "MCP: list_procedures. Inspect the loaded base and source mappings with CLI rea analyze --provider ghidra --json " + @@ -4916,7 +4965,7 @@ public final class ReaGhidraBridge extends HeadlessScript { // without retaining its cause. This producer reason is not bad syntax. if (exception.getDescription().equals("Stack overflow during pattern compilation")) throw regexStackExhausted(expression, "compiling"); - throw new RequestFailure("invalid_request", "Invalid regex pattern " + expression + ": " + exception.getDescription() + " at index " + exception.getIndex()); + throw new RequestFailure("invalid_regex_pattern", "Invalid regex pattern " + expression + ": " + exception.getDescription() + " at index " + exception.getIndex()); } catch (StackOverflowError exhausted) { throw regexStackExhausted(expression, "compiling"); diff --git a/bridge/native/ReaNativeUI.swift b/bridge/native/ReaNativeUI.swift index b123a57f24..2c7bd73992 100644 --- a/bridge/native/ReaNativeUI.swift +++ b/bridge/native/ReaNativeUI.swift @@ -65,7 +65,10 @@ func observe(_ request: Request) async throws -> [String: Any] { guard SHA256.hash(data: executableBytes).map({ String(format: "%02x", $0) }).joined() == request.sha256 else { try fail("target-mismatch", "Running executable bytes differ from the active target digest") } - guard let windows = CGWindowListCopyWindowInfo([.optionIncludingWindow], request.window_id) as? [[String: Any]], + // .optionIncludingWindow omits a selected window while it is off screen, which + // is not absence: select from every window, and let capture report what it can. + guard let windows = (CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]])? + .filter({ ($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == request.window_id }), windows.count == 1, let window = windows.first, (window[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value == request.pid, (window[kCGWindowLayer as String] as? NSNumber)?.intValue == 0, diff --git a/bridge/native/rea_lldb_tracer.py b/bridge/native/rea_lldb_tracer.py index dccceb56fe..11e365b8cc 100644 --- a/bridge/native/rea_lldb_tracer.py +++ b/bridge/native/rea_lldb_tracer.py @@ -26,7 +26,6 @@ ARGUMENT_REGISTERS = { "x86_64h": ["rdi", "rsi", "rdx", "rcx", "r8", "r9"], } MAX_SELECTOR_BYTES = 1024 -MAX_REPORTED_LOCATIONS = 64 MAX_OTHER_STOPS = 256 MAX_TRACE_BYTES = 8 * 1024 * 1024 MAX_TRACE_FRAMES = 65_536 @@ -505,20 +504,19 @@ def _breakpoint_report(target, created, config): if not _wanted(config["breakpoints"][index], location["symbol"] or ""): continue count += 1 - if len(locations) < MAX_REPORTED_LOCATIONS: - remaining_bytes = ( - MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes - ) - if not _fits_trace_budget(location, remaining_bytes): - locations_truncated = True - continue - location_bytes = len(json.dumps(location).encode("utf-8")) - addition = location_bytes + (1 if locations else 0) - if addition > remaining_bytes: - locations_truncated = True - continue - locations.append(location) - retained_location_bytes += addition + remaining_bytes = ( + MAX_BREAKPOINT_LOCATION_BYTES - retained_location_bytes + ) + if not _fits_trace_budget(location, remaining_bytes): + locations_truncated = True + continue + location_bytes = len(json.dumps(location).encode("utf-8")) + addition = location_bytes + (1 if locations else 0) + if addition > remaining_bytes: + locations_truncated = True + continue + locations.append(location) + retained_location_bytes += addition report.append({"index": index, "location_count": count, "locations": locations}) return report, locations_truncated diff --git a/bridge/native/test_rea_lldb_tracer.py b/bridge/native/test_rea_lldb_tracer.py index 9cce55ff28..e1c3e66e97 100644 --- a/bridge/native/test_rea_lldb_tracer.py +++ b/bridge/native/test_rea_lldb_tracer.py @@ -430,6 +430,34 @@ class NativeTraceBudgetTests(unittest.TestCase): self.assertEqual(report[0]["locations"], []) self.assertFalse(locations_truncated) + def test_breakpoint_report_keeps_all_locations_under_aggregate_budget(self): + class Location: + def __init__(self, index): + self.index = index + + def GetAddress(self): + return self.index + + class Breakpoint: + def GetNumLocations(self): + return 65 + + def GetLocationAtIndex(self, index): + return Location(index) + + with patch.object( + tracer, + "_location", + side_effect=lambda address, _target: {"address": address, "symbol": None}, + ), patch.object(tracer, "_wanted", return_value=True): + report, locations_truncated = tracer._breakpoint_report( + object(), [[Breakpoint()]], {"breakpoints": [{}]} + ) + + self.assertEqual(report[0]["location_count"], 65) + self.assertEqual(len(report[0]["locations"]), 65) + self.assertFalse(locations_truncated) + def test_breakpoint_report_bounds_aggregate_location_json(self): tracer.lldb.LLDB_INVALID_ADDRESS = -1 symbol_name = ( diff --git a/bridge/process/ProcessRunTokenReader.swift b/bridge/process/ProcessRunTokenReader.swift index 599fbdb1b4..931eb48750 100644 --- a/bridge/process/ProcessRunTokenReader.swift +++ b/bridge/process/ProcessRunTokenReader.swift @@ -228,6 +228,22 @@ func processRunTokenObservation( parse: Result, codeSigningFlags: () -> UInt32? ) -> RunTokenObservation { + // Native inspection restrictions apply even when argv is incomplete or + // overwritten, before parsing reaches the environment. Keep this fact at + // the native boundary; cleanup still decides whether the process is related. + func withheldEnvironment() -> RunTokenObservation? { + guard let flags = codeSigningFlags() else { return nil } + if (flags & csPlatformBinary) != 0 { + return unavailable(pid: pid, reason: "platform_binary_environment_withheld") + } + if (flags & csRestrict) != 0 { + return unavailable(pid: pid, reason: "restricted_process_environment_withheld") + } + return nil + } + func unavailableEnvironment(_ reason: String) -> RunTokenObservation { + withheldEnvironment() ?? unavailable(pid: pid, reason: reason) + } switch parse { case .success(let parsed): if let runId = parsed.runId { @@ -238,40 +254,25 @@ func processRunTokenObservation( if parsed.environmentEntryCount > 0 { return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil) } - let flags = codeSigningFlags() - if let flags, (flags & csPlatformBinary) != 0 { - return unavailable(pid: pid, reason: "platform_binary_environment_withheld") - } - if let flags, (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } + if let withheld = withheldEnvironment() { return withheld } return RunTokenObservation(pid: pid, state: "readable", run_id: nil, reason: nil) case .failure(let failure): switch failure { case .environmentUnavailable: - let flags = codeSigningFlags() - if let flags, (flags & csPlatformBinary) != 0 { - return unavailable(pid: pid, reason: "platform_binary_environment_withheld") - } - if let flags, (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } - return unavailable(pid: pid, reason: "environment_unavailable") + return unavailableEnvironment("environment_unavailable") case .ambiguousEnvironmentBoundary: - if let flags = codeSigningFlags(), (flags & csRestrict) != 0 { - return unavailable(pid: pid, reason: "restricted_process_environment_withheld") - } - return unavailable(pid: pid, reason: "ambiguous_environment_boundary") + return unavailableEnvironment("ambiguous_environment_boundary") + // These failures already establish that token bytes were available. case .duplicateToken: return unavailable(pid: pid, reason: "duplicate_run_token") case .invalidTokenEncoding: return unavailable(pid: pid, reason: "invalid_run_token_encoding") case .appleVectorUnavailable: - return unavailable(pid: pid, reason: "apple_vector_unavailable") + return unavailableEnvironment("apple_vector_unavailable") case .invalidBuffer: - return unavailable(pid: pid, reason: "malformed_procargs") + return unavailableEnvironment("malformed_procargs") case .invalidBufferAt(let stage): - return unavailable(pid: pid, reason: "malformed_procargs_\(stage.rawValue)") + return unavailableEnvironment("malformed_procargs_\(stage.rawValue)") } } } diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py index 86ec3f623b..a9c112fb70 100644 --- a/bridge/pwntools/layout_impl.py +++ b/bridge/pwntools/layout_impl.py @@ -16,10 +16,10 @@ PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} OUTPUT_BYTES = 64 * 1024 * 1024 # (e_machine, ELF class bits, byte order) -> architecture pwntools must also report. -PROFILES = {(62, 64, "little"): "amd64", (3, 32, "little"): "i386", (40, 32, "little"): "arm", - (8, 32, "little"): "mips", (8, 32, "big"): "mips"} -PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF32 i386, ELF32 ARM little-endian " - "or ELF32 MIPS little- or big-endian objects.") +PROFILES = {(62, 64, "little"): "amd64", (183, 64, "little"): "aarch64", (3, 32, "little"): "i386", + (40, 32, "little"): "arm", (8, 32, "little"): "mips", (8, 32, "big"): "mips"} +PROFILE_SCOPE = ("Layout profile requires ELF64 x86-64, ELF64 AArch64 little-endian, ELF32 i386, " + "ELF32 ARM little-endian or ELF32 MIPS little- or big-endian objects.") class LayoutFailure(Exception): diff --git a/docs/adb-device-analysis.md b/docs/adb-device-analysis.md index 1dfcb826da..efb8bf2b34 100644 --- a/docs/adb-device-analysis.md +++ b/docs/adb-device-analysis.md @@ -203,8 +203,12 @@ and during shutdown. `uninstall_adb_package` declare `mutatesTarget` and never run implicitly: pushes check for existing device files before transfer and refuse them unless `overwrite` is set. A failed or unexpected existence probe stops - transfer even with `overwrite`; only a clean `test -e` exit of 1 with no - output is interpreted as absence. The check is not atomic, so a different + transfer even with `overwrite`. A completed `test -e` exit of 1 establishes + absence only with empty stdout and either empty stderr or a recognized, + completed adb-server startup notice sequence. Startup notices are host + observations, not remote-path diagnostics; successful push receipts retain + them in `raw_result.device_path_probes`. Incomplete startup, extra diagnostics, + truncation and cancellation do not establish absence. The check is not atomic, so a different process can create the path before `adb push` writes it. When available, the device's `sha256sum` verifies the transfer. A missing command or output without a recognizable digest reports `device_digest_source: "unavailable"` with an diff --git a/docs/agent-environments.md b/docs/agent-environments.md index 8bcc62be86..71fd35bd65 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -1149,7 +1149,12 @@ global conflict involving an equal pair is not used to normalize either pair; th A step agrees when the statuses and the normalised bodies are equal. The report gives agreement overall, per probe and per tool, `agreement_ignoring_order` for steps that differ only in item order, and each step's differences, at most 20, -with a JSON pointer and both values cut short: +with a JSON pointer and both values cut short. Each step reports `differences_total` before that cap, +`differences_omitted`, and `differences_truncated` (true when entries were omitted). Agreement and categories use the +complete comparison. Omitted entries are not retained, so `--full-output` cannot recover them; inspect the original +capture and probe Evidence to investigate beyond the returned pointers. + +The difference categories are: | Category | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------ | diff --git a/docs/android-analysis.md b/docs/android-analysis.md index 4f42dd2fc0..932e5f4fa8 100644 --- a/docs/android-analysis.md +++ b/docs/android-analysis.md @@ -73,6 +73,9 @@ spellings; the original SDK declaration strings remain unchanged. it remains null for unknown provider target SDKs, nonliteral declarations, or components with filters targeting API 31 or later that omit the now-required explicit declaration. A limitation records the latter validation error. +As in Android's package parser, an activity, alias or receiver filter without +an `` is discarded and does not count toward either rule; a service +keeps such a filter. `intent_filter_count` still counts every declared filter. Provider authorities and all inventory dimensions are bounded. `status: "partial"` and `limitations` identify omitted declarations or values; a capacity limitation appears only when an actual collection limit was reached. diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md index 56427673af..cf074b10c3 100644 --- a/docs/binary-diagnostics.md +++ b/docs/binary-diagnostics.md @@ -9,9 +9,11 @@ ordinary binary inspection use the same modular capability. ## Bring your own engine Real verification covers **Linux x64** hosts and **EXEC/DYN/REL** objects in -these profiles: ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 -little- or big-endian MIPS. Other machines, classes and byte orders, including -AArch64 and big-endian ARM, return `unsupported_target`. Provide an absolute +these profiles: ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 +little-endian ARM and ELF32 little- or big-endian MIPS. Other machines, classes +and byte orders, including big-endian ARM, return `unsupported_target`. Other +hosts, including Linux arm64, return `capability_unavailable` naming the +detected host before any target is read. Provide an absolute Python executable whose environment already contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 22265bd53f..c2830a91a0 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -500,7 +500,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex connection/frame inventories; matching direction/opcode sequences alone do not prove alignment after drops. Redirect resource types use only shared known metadata: an omitted type leaves equality unknown rather than proving a modification. -- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. +- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. Argument types are listed by CDP argument position. An absent or non-array argument inventory is `null`, and a malformed argument keeps its position as `unknown`; either makes `console_events` incomplete, while an observed empty inventory remains `[]`. A malformed stack or top stack frame is not replaced by its caller: the console event is excluded, and a request initiator location or WebMCP `registration_source` is `null`; each is reported as `invalid_protocol_value`. Source locations are CDP's zero-based lines and UTF-16 columns. A negative column, which Chrome reports on the first line of some inline scripts, is kept as reported and does not locate a column in the source URL; an invalid line or a non-integer column is `null` (WebMCP `registration_source` is `null`) and is reported as `invalid_protocol_value`. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim with their original `argument_index`; objects, getters, and remote properties are never expanded. Text capture is `unavailable` when the argument inventory is unknown. - WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained. - Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`. - Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers. diff --git a/docs/browser-scenario-contract.md b/docs/browser-scenario-contract.md index 2c9ed3f35f..614ecff971 100644 --- a/docs/browser-scenario-contract.md +++ b/docs/browser-scenario-contract.md @@ -9,7 +9,10 @@ liveness deadlines. `INPUT_JSON` may be inline JSON or a JSON file path. The minimal request contains only browser launch/connect selection, `start_url`, and at least one `actions` entry. Launch `headless` defaults to `true` and can -be set to `false` where the host supports a visible browser. Navigations follow +be set to `false` where the host supports a visible browser. Launched Chromium +keeps its sandbox; on hosts that cannot provide one, such as root in a +container, set `REA_BROWSER_NO_SANDBOX=true` for the REA process. Termux on +Android always launches without it. Navigations follow the destinations named by the scenario and the page's ordinary browser requests, and [request steps](#request-steps) send only to the start URL's origin; there is no separate origin allowlist. Request steps use a temporary dispatch diff --git a/docs/cli.md b/docs/cli.md index a0a7c6dae3..c4354486ed 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,6 +39,17 @@ pass an absolute path. REA reads and authenticates the file, then projects it like retained Evidence, so a large file is bounded by the file reader rather than charged as inline input. +An argument that accepts inline JSON or a JSON file path also accepts `-`, which +reads the request from standard input, so a generated request needs no +temporary file: + +```bash +jq -nc '{source: {kind: "evidence-file", path: "app-evidence.json"}, view: {kind: "summary"}}' | rea inspect-analysis-view - +``` + +Standard input is read once as a single UTF-8 JSON document without a +byte-order mark, and it is limited to 8 MiB. Put a larger request in a file. + Local file inspection commands reject empty paths and NUL bytes before resolving relative inputs, auxiliary files or output destinations. An empty string never selects the working directory; use an explicit `.` when selecting that directory @@ -270,6 +281,17 @@ Format versions 6 through 8 retain header, block and directory facts with none/LZ4/LZ4HC block decompression. See [UnityFS](unityfs.md) for encryption and LZMA coverage boundaries. +Inspect a WeChat mini-program package without WeChat or an unpacker: + +```sh +rea inspect-artifact app.wxapkg --detail summary --evidence_output evidence.json --json +rea extract-artifact app.wxapkg --json +``` + +Plaintext packages retain header, table and member facts with verbatim member +bytes. `V1MMWX` packages are detected as encrypted coverage and refused on +extraction. See [wxapkg](wxapkg.md) for layout and coverage boundaries. + Inspect an ESP32-family app or bootloader image without esptool: ```sh @@ -569,6 +591,17 @@ prints a failure, so it omits the same-session reuse and `parameter`) for a new invocation and rerun. For example, a Ghidra decompile deadline names `REA_GHIDRA_DECOMPILE_TIMEOUT_SECONDS`. +`rea debug-with-gdb` and `rea debug-with-rizin` hold their debugger only for the +command's own process. If cleanup cannot confirm the debugger stopped, they print +`cleanup_incomplete` and exit nonzero without stopping it. `remediation.action` +names the process, as does `retained_debugger_pids` in `details.diagnostics`, or +in `details.diagnostics.cleanup_error.details.diagnostics` when the command also +failed or was interrupted. Exiting closes the debugger's command and output pipes. +REA does not establish what the debugger or its target does next, so check that +process. An MCP server that shuts down likewise exits with status 1 instead of +waiting on a debugger it could not stop. It names the process on stderr and as +`retained_debugger_pids` in its error log. + If one-shot analysis fails and closing its session also fails, the original error details remain inline and `details.cleanup_error` describes the later close failure. Each diagnostic retains its own reported resources; the later @@ -601,6 +634,9 @@ Saved snapshots include only cache entries whose subject identifies the selected target. Unbound observations and observations of child artifacts remain available in the open session and its Evidence bundle; they are excluded from the target's saved replay entries without changing their subjects. +The snapshot also retains available Evidence linked by selected records, including +transitive sources with an unbound or different subject. Those sources are not +replay entries for the selected target. Unrelated records remain excluded. Snapshots retain eligible target-scoped question histories in full, including mutation Evidence recorded while another target was active. Observations and @@ -615,6 +651,11 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat ``` An exact CLI cache hit is normally read before starting a provider process. +`decompile --provider ghidra --snapshot ...` also retains its single selected +Program's pseudocode result, even without an explicit document selector. Old +snapshots that contain only the Evidence need one new query to add that replay +entry. This does not retain every body from `decompile-all` or make a persistent +session's implicit document replayable. On Windows, Ghidra first admits the selected source through its native snapshot owner, so replay can start that provider. In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically @@ -667,6 +708,10 @@ results and Evidence. These are cached observations, not a saved provider database: only eligible exact queries can reuse a result, and new or live queries can still require provider startup. The CLI uses the same snapshot format and preserves these records when loading and updating it. +When an MCP session replays a provider observation, its delivered Evidence identifies +the original observation through `evidence_links` and reports that the +provider was not rerun. The original and its available source records remain +in the investigation bundle. ## Import, export and compare Evidence @@ -1174,9 +1219,12 @@ treated as malformed. exact array path with `--filter-output` and provide comma-separated direct scalar columns with `--columns`; column order is the output column order: +For an artifact inventory request that selects the `occurrences` collection +and includes `logical_path` and `artifact_id` in its view fields: + ```bash rea inspect-analysis-view request.json --format csv \ - --filter-output normalized_result.rows --columns name,address + --filter-output normalized_result.items --columns logical_path,artifact_id ``` The first line is a REA metadata record, diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 3461bd6855..5f04937b10 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -45,6 +45,10 @@ the client's configured command or reproduce the client's environment. If doctor passes but the client disconnects, inspect the client's startup error and registered launcher; see [verification scope](client-verification.md#launch-environment-verification). +`rea doctor` has the same limit for provider checks, and reports a provider setting +the registration's file does not carry as a +[non-blocking unknown](installation.md#check-readiness-for-your-task). + ## Runtime settings and client deadlines `update_analysis_settings` changes curated defaults inside the running REA @@ -306,6 +310,22 @@ Kimi Code also discovers personal skills in `~/.agents/skills` and project-local and verifies the matching skill files through the normal shared-skill flow. Start a new Kimi Code session to pick up MCP and skill changes. +## LM Studio + +For [LM Studio](https://lmstudio.ai/docs/app/mcp), setup writes `mcpServers.rea` +into `mcp.json` in the LM Studio home directory. Setup finds that directory as +LM Studio does: the path stored in `~/.lmstudio-home-pointer`, otherwise +`~/.cache/lm-studio` when it exists, otherwise `~/.lmstudio`. Setup only reads +the pointer file. LM Studio parses `mcp.json` as strict JSON, so setup rejects +a file with comments, trailing commas, or a byte order mark without a backup +or a write. The entry carries `command`, `args`, and `env`, and setup keeps +every other server. Local models often have small context windows, so setup +selects `REA_MCP_INPUT_SCHEMA_PROFILE=compact` unless the stored entry or the +setup environment selects a profile explicitly. Doctor reports an entry with no +explicit profile as stale, as for Kimi Code. LM Studio 0.4.26 on macOS loaded +this registration, and a local model called an REA tool through it. Linux and +Windows remain unverified. + ## Pi For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md), @@ -628,21 +648,20 @@ writing a registration ZCode cannot load. ## FreeBuff -For FreeBuff, the proposed integration writes `mcpServers.rea` into the user-scope -`~/.agents/mcp.json` shared-agent file. Entries carry `command`, `args`, and -`env`; stdio is the default transport. The proposed personal skill location is -`~/.agents/skills`, so setup installs its skill copy there. Because the same +For FreeBuff, `rea setup --client freebuff` writes `mcpServers.rea` into the +user-scope `~/.agents/mcp.json` shared-agent file. Entries carry `command`, +`args`, and `env`; stdio is the default transport. The personal skill location +is `~/.agents/skills`, so setup installs its skill copy there. Because the same file doubles as ZCode's user-scope fallback (see above), a `freebuff` -registration is also in the file ZCode uses until ZCode defines its own native server. -Setup and doctor detect FreeBuff from its own profile directory -(`~/.config/freebuff-desktop`), not from the shared `.agents` folder that -REA's skill installs create for every client. +registration is also in the file ZCode uses until ZCode defines its own native +server. Setup and doctor detect FreeBuff from its own profile directory +(`~/.config/freebuff-desktop`), not from the shared `.agents` folder that REA's +skill installs create for every client. -The configuration write and personal skill locations are proposed integration -boundaries. FreeBuff MCP loading and skill discovery have not been verified -with an installed native FreeBuff client/version; filesystem tests cover REA's -editing, discovery, and ownership behavior only. Automatic applicability -requires the FreeBuff profile directory even when the shared MCP file exists. +FreeBuff MCP loading and skill discovery have not been verified with an +installed native FreeBuff client/version; filesystem tests cover REA's editing, +discovery, and ownership behavior only. Automatic applicability requires the +FreeBuff profile directory even when the shared MCP file exists. When uninstall cannot establish FreeBuff or another shared-storage reader from product discovery, a malformed or unreadable shared MCP file is retained unchanged and does not stop other REA removals, including `--purge-data`. When @@ -650,8 +669,9 @@ FreeBuff or ZCode product applicability is established or unresolved, the same malformed shared file stops uninstall before removals. See [Diagnose, update, and remove](installation.md#diagnose-update-and-remove). -FreeBuff setup support requires `rea-agents@6.8.0` or later. Older versions -reject `rea setup --client freebuff` as an unsupported client. See the +FreeBuff setup support requires `rea-agents@6.8.0` or later (including the +current npm `latest` release). Older versions reject +`rea setup --client freebuff` as an unsupported client. See the [installation guide](installation.md#supported-agents) for client availability. ## DeepSeek Harness diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index 9eccda38fb..c6ea9dde98 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -121,9 +121,9 @@ launch environment affects the next REA process. is the byte-budget shape: a decimal safe integer with a 10 MiB floor (`MINIMUM_RESPONSE_BYTES`), the same shared schema, and no silent fallback. A change needs a REA restart and a matching client receive buffer. It does not -raise REA's reserved string limit or Node's string ceiling. The setup -allowlist does not copy this setting. A new headroom setting should, following -the regex deadline. +raise REA's reserved string limit or Node's string ceiling. Like the regex +deadline, it is in `SELECTED_REGISTRATION_SETTINGS`, so setup forwards an +explicit selection into the client registration. A per-call budget can have a runtime default without having an environment setting. JavaScript `max_heap_mb` diff --git a/docs/desktop-applications.md b/docs/desktop-applications.md index 58c8926ee8..64f4bf9e2c 100644 --- a/docs/desktop-applications.md +++ b/docs/desktop-applications.md @@ -110,6 +110,15 @@ not establish full metadata validity or correspondence to a native binary; version 24 inspection still applies only to supported header layouts. See [IL2CPP metadata](il2cpp-metadata.md). +In a managed directory with a Unity Mono witness, `Assembly-CSharp.dll` and +`Assembly-CSharp-firstpass.dll` get an `inspect_managed_members` probe listed +first; project-named asmdef assemblies keep only the ordinary managed probes. +The same probe is offered for those names in `Managed/` of a +`_BackUpThisFolder_ButDontShipItWithYourGame` folder (or when that +folder is the root); whether its IL matches the shipped IL2CPP code is unknown. +Point the scan at the game folder: a root that is the `_Data` or +`Managed` folder itself has no Mono witness path and gets no member probe. + Candidates are derived or inferred from these facts and do not prove loading or execution. BepInEx, MelonLoader, Easy Anti-Cheat and BattlEye labels identify path conventions only; runtime presence stays unknown. No bypass is provided. diff --git a/docs/elf-notes.md b/docs/elf-notes.md index dff138c6c9..38d27c3787 100644 --- a/docs/elf-notes.md +++ b/docs/elf-notes.md @@ -38,6 +38,12 @@ were examined; an unavailable section name does not erase those note facts. For GNU notes, `gnu.build_id_hex`, `gnu.abi_tag`, or `gnu.gold_version` are filled when the descriptor matches that type. Other owners keep `gnu` null. +For Linux `CORE` notes, `core.prpsinfo`, `core.file`, `core.siginfo`, or +`core.auxv` are decoded for process arguments and status (`NT_PRPSINFO`), mapped +file memory regions and paths (`NT_FILE`), fault signal and code (`NT_SIGINFO`), and +auxiliary vectors (`NT_AUXV`) across 32-bit and 64-bit images. Other owners or unparsed +notes keep `core` null. + `PT_NOTE` and `SHT_NOTE` are examined independently. The same file range may appear twice when both tables declare it. @@ -48,7 +54,7 @@ the image never carried notes in another container or overlay. The selected file is limited to 64 MiB by default (512 MiB maximum). At most 32 note entries are examined by default across every table. Retained note -names, section names, and GNU descriptor text share a scan budget based on +names, section names, GNU descriptor text, and core dump string representations share a scan budget based on the source bytes plus any decoded section-name table, capped at `max_file_bytes`. The raw source and decoded table must also fit together within that ceiling. If admission fails, the error reports the required diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md index 0b4d97bd65..e215f0028e 100644 --- a/docs/evm-bytecode.md +++ b/docs/evm-bytecode.md @@ -28,6 +28,15 @@ The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`; no system tools and changes no user configuration. [Upstream provenance and license](https://github.com/morluto/rea/blob/main/third_party/evmole/README.md) are packaged. +Limiter discovery validates the selected path without launching it. A relative +path or directory is reported as `not_configured`, a missing file as +`executable_missing`, and denied access or another filesystem-probe failure as +`runtime_unavailable`. Recovery names the selected path and +`REA_EVM_PRLIMIT_COMMAND`; denied access asks for execute permission and +filesystem access. The original admission or system code stays in diagnostics. +An available filesystem probe does not verify the selected tool's version or +util-linux compatibility, and REA does not substitute another command. + ## Evidence semantics - `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and diff --git a/docs/firmware-analysis.md b/docs/firmware-analysis.md index 28d70d4f27..4ff0899e93 100644 --- a/docs/firmware-analysis.md +++ b/docs/firmware-analysis.md @@ -26,6 +26,9 @@ REA does not install these tools, their dependencies or system packages. It checks the tool's `--version` banner when executing a request. Binwalk must print `binwalk ` and Unblob must print the version alone. Inspection and extraction have independent prerequisites; one does not require the other. +Capability discovery recommends `REA_BINWALK_COMMAND` for missing inspection +and `REA_UNBLOB_COMMAND` for missing extraction. Configuring the other engine +does not enable the unavailable operation. Both require util-linux `prlimit`, normally `/usr/bin/prlimit`; override its absolute path with `REA_FIRMWARE_PRLIMIT_COMMAND` if needed. `rea doctor` does not currently inspect firmware readiness. Engine selection/version checks run @@ -242,9 +245,12 @@ tree semantics, vulnerability scanning, emulation or hardware runtime capture. 3, allowed range 1–10. Staging budgets are polled and rechecked after exit; short-lived overshoot is -possible. The per-file process limit is the larger of the selected payload budget -and the fixed report/diagnostic file allowances, so a small payload selection -still permits the mandatory report. A tighter inherited host limit still applies. +possible. The per-file process limit is one byte above the larger of the selected +payload budget and the fixed report/diagnostic file allowances, so a small payload +selection still permits the mandatory report, and a staged file the kernel stops +at the limit exceeds its budget instead of passing as complete. A tighter +inherited host limit still applies and can remove that byte, so a staged file +that reaches the installed limit is rejected as possibly truncated. The inherited file-size limit bounds each file, not total storage; address-space limits are per process, not an aggregate memory quota. Worker count is not CPU affinity for every external extractor. These limits do not diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index 2d18753d63..7ffb31d2d6 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -109,9 +109,13 @@ server; older servers keep the complete request after schema discovery. header check does not establish valid table contents or exported symbols. - **`libflutter` facts** are per-ABI observations: byte size, SHA-256 digest, GNU build IDs from ELF-declared `PT_NOTE` / `SHT_NOTE` ranges, - and any _labeled_ strings the build - carries. Release engine builds often strip the `Dart VM version` string — - absence is reported as `null`, not guessed. Android clang/LLVM toolchain + and Dart/toolchain strings the build carries. `dart_version` accepts the + labeled `Dart VM version:` form and the unlabeled Dart + `Version::String()` form (` () ()` with an + optional ` on "_"` suffix) that release engines embed. An + unlabeled match is a candidate based on its shape; it is not bound to a + Dart symbol and another string can match. `null` means no supported pattern + was found, not proof that no version exists. Android clang/LLVM toolchain lines are reported verbatim as build provenance. `build_id_candidates` preserves distinct IDs. `build_id` is populated only when all declared note ranges were examined and one ID was found; @@ -122,10 +126,13 @@ server; older servers keep the complete request after schema discovery. Compressed `SHT_NOTE` sections are skipped with an unsupported-compression limitation and partial coverage; IDs from other note ranges remain candidates. Encoded note storage is never interpreted as note headers or inflated. -- Labeled strings must fit their complete grammar. `dart_version_capped` - and `toolchain_capped` independently report labels omitted by grammar or - projection bounds; either makes identification coverage partial. Values - split across read chunks are retained without publishing truncated prefixes. +- Version and toolchain strings must fit their complete grammar. + `dart_version_capped` and `toolchain_capped` independently report labels + omitted by grammar or projection bounds; either makes identification + coverage partial. Values split across read chunks are retained without + publishing truncated prefixes. Unlabeled runs that miss the + `Version::String()` grammar are ignored and do not set + `dart_version_capped`. - **`coverage: "partial"`** also appears when an ABI carries `libapp.so` without `libflutter.so` (or vice versa). - The APK itself is digested before parsing, so `target.sha256` binds the diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index ad65004b3c..cc0b48a899 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -330,9 +330,11 @@ Without bounds, loaded memory blocks are eligible, in Ghidra address order. Blocks that are not loaded (file parts such as `.symtab`) and overlay blocks are listed in `searched.skipped_not_loaded` and are not searched; pass `start` or `end` in their address space to search them. -Overlapping matches are included; matches never cross block boundaries. -Each match reports its address, segment, unique source file offset (or null), -and containing procedure (or null). +Overlapping matches are included, including across adjacent eligible initialized +blocks in the same address space. Gaps and excluded or uninitialized blocks break +a search run. Each match reports its address, starting segment, unique source file +offset for that starting address (or null), and containing procedure (or null). +A starting offset does not imply the full match is contiguous in the file. Pages default to 100 matches and accept 1 to 1000. Pass `next_cursor` as `cursor` with the same target, profile, pattern, mask and bounds to continue. `complete` diff --git a/docs/godot-pck.md b/docs/godot-pck.md index 19b6f1d938..748fcf0d2d 100644 --- a/docs/godot-pck.md +++ b/docs/godot-pck.md @@ -25,6 +25,33 @@ by default; `--integrity-policy record-and-continue` retains the observed bytes and an explicit untrusted contradiction. An all-zero MD5 is an unknown claim, not a verified digest. +Artifact comparison pairs members by `logical_path`, the stored path without +`res://`, and compares their bytes and occurrence facts. It does not compare +`godot-pck-entry` metadata. Godot 4.3 +[stores `res://` paths](https://github.com/godotengine/godot/blob/77dcf97d82cbfe4e4615475fa52ca03da645dbd8/editor/export/editor_export_platform.cpp#L228) +and [keys members by them](https://github.com/godotengine/godot/blob/77dcf97d82cbfe4e4615475fa52ca03da645dbd8/core/io/file_access_pack.cpp#L50-L52). +Godot 4.4.1 +[stores paths without the prefix](https://github.com/godotengine/godot/blob/49a5bc7b616bd04689a2c89e89bda41f50241464/editor/export/editor_export_platform.cpp#L257-L260) +and [removes it before keying](https://github.com/godotengine/godot/blob/49a5bc7b616bd04689a2c89e89bda41f50241464/core/io/file_access_pack.cpp#L48-L50). +Each version finds `res://scenes/main.gd` in its own export, but comparing the +stored `source_path` would mark every member changed between such exports. +`logical_path` names the member Godot's lookup finds, except in two cases. +REA normalizes Unicode to NFC, so a member renamed only between Unicode forms +compares unchanged, although Godot treats it as another path. Godot reads `\` +as `/` and REA keeps it literal, so a member spelled with backslashes compares +as removed and added. Directory order and offsets are layout. The encryption +bit, the only member flag REA admits, is the occurrence's `encrypted` field. A +member whose encryption changes compares `unknown` in `metadata` and +`availability`: the change is known, but REA does not read encrypted bytes, +so whether the script changed is unknown. Godot's exporter +[stores the MD5 of a member's original bytes](https://github.com/godotengine/godot/blob/a13da4feb8d8aefc283c3763d33a2f170a18d541/editor/export/editor_export_platform.cpp#L465-L468), +as the 4.3 and 4.4.1 exporters do, so the declared MD5 does not depend on the +encryption IV. Encrypted members whose declared MD5s differ therefore also +compare `unknown` in `metadata` and `availability`: the MD5s declare +different scripts, but REA cannot verify them, and equal MD5s add nothing for +the same reason. An encrypted member with an MD5 on only one side stays +`unknown`. + ## CLI Start with a compact summary and keep the complete Evidence bundle: diff --git a/docs/harmony-analysis.md b/docs/harmony-analysis.md index a671a2ef7d..72fbeec5c1 100644 --- a/docs/harmony-analysis.md +++ b/docs/harmony-analysis.md @@ -36,6 +36,8 @@ The MCP tool `project_harmony_application_graph` takes the same - exact component paths, artifact IDs, and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing) +- `unclassified_components` with the same paths and content identities for + authenticated files outside those categories and the App Pack child list - a packaging-model hint: Stage is claimed only from an exactly cased `module.json` entry outside a classified resource path, FA only from `config.json` outside a classified resource path, otherwise `unknown` @@ -55,6 +57,24 @@ being classified as code files. Ark and JavaScript runtime hints summarize the classified bytecode and JavaScript lists; supported source extensions share the same classification rule as their component rows. +`unclassified_components` preserves files such as `pkgContextInfo.json`, +`.pages.info`, a HarmonyOS `.har` shared library, and a non-certificate +`META-INF` entry without interpreting their contents. The `.har` suffix also +names HTTP Archive JSON, so identifying one needs verified ZIP bytes rather than +a path rule. Different paths sharing the same bytes remain separate entries. App +Pack children of an App Pack root are already reported in `app_pack_children`, +so they are not repeated as unclassified files. A nested `.hap` or `.hsp` member +of a HAP or HSP root is disclosed in `unclassified_components` instead, because +child-package inventory is not recursive. Unreadable or encrypted content remains +in `unavailable_components` with unknown identity instead. + +`coverage.status: "complete-within-inventory"` describes the authenticated +source inventory and bounded bridge projection, not semantic understanding of +every file. Unclassified files do not make a complete inventory partial; missing +inventory pages, source coverage limits, unavailable content and bridge limits +still qualify coverage independently. An empty unclassified list on a partial +inventory does not establish that the whole package has no unclassified files. + Every claim is path-presence only. Manifest semantics (module identity, abilities, entry points, permissions, dependencies), Ark bytecode contents and versions, resource resolution, signing verification, and observed native diff --git a/docs/hex-records.md b/docs/hex-records.md index 86af9665c1..2cffe3b7a3 100644 --- a/docs/hex-records.md +++ b/docs/hex-records.md @@ -36,7 +36,10 @@ regions and are never filled. Region `format_metadata` (`hex-record-region`) records `start_address`, `end_address`, `length`, `record_count`, and `first_line`/`last_line`: the -1-based line span of the contributing data records. +1-based line span of the contributing data records. `compare_artifacts` +compares none of these fields: a region's name encodes its start and end +addresses, its length follows from them, and record and line counts are +layout. Root `format_metadata` (`hex-records`) records: diff --git a/docs/index.md b/docs/index.md index e691d2eec3..596625259a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -47,20 +47,19 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md). ## Choose a guide -| Task | Guides | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | -| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | -| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | -| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md), [Release exposure](release-exposure.md) | -| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [U-Boot uImage](uimage.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [ESP images](esp-image.md), [ESP flash images](esp-flash-image.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | -| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | -| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | -| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | -| Capture and compare a command | [Process capture](process-capture.md) | -| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | +| Task | Guides | +| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Identify a desktop installation | [Runtime candidates, evidence and follow-up files](desktop-applications.md) | +| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [CPython pyc files](python-pyc.md), [PE certificates](pe-certificates.md), [PE imports](pe-imports.md), [PE TLS/load config](pe-load-config.md), [ELF notes](elf-notes.md), [Linux kernel modules](kernel-modules.md), [Apple IPS](apple-ips.md), [Erlang BEAM](erlang-beam.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) | +| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [GameCube DOL](ghidra-dol.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [Kuna](kuna-provider.md), [JEB](jeb-analysis.md) | +| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) | +| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md), [Electron fuses](electron-fuses.md), [Release exposure](release-exposure.md) | +| Inspect assemblies, packages, or firmware | [Godot PCK](godot-pck.md), [UnityFS](unityfs.md), [wxapkg](wxapkg.md), [U-Boot uImage](uimage.md), [.NET](managed-code-analysis.md), [Python packages](python-packaged-applications.md), [Android](android-analysis.md), [APK signatures](apk-signature.md), [Apktool resources](apktool-resource-analysis.md), [ADB acquisition](adb-device-analysis.md), [Apple](apple-application-analysis.md), [Harmony](harmony-analysis.md), [firmware](firmware-analysis.md), [ESP images](esp-image.md), [ESP flash images](esp-flash-image.md), [WASM](wasm-artifacts.md), [EVM](evm-bytecode.md) | +| Observe or compare a browser scenario | [CDP observation](browser-observation.md), [scenarios](browser-scenario-contract.md), [runtime evidence](web-runtime.md), [historical captures](web-network-captures.md) | +| Serve recorded behavior as an agent environment | [Agent environments](agent-environments.md) | +| Trace captured website source | [Source locations](web-source-location.md), [module traces](website-module-trace.md), [script export](website-script-export.md) | +| Capture and compare a command | [Process capture](process-capture.md) | +| Use CLI/MCP and retain evidence | [CLI](cli.md), [MCP contracts](mcp-contracts.md), [agent prompts](mcp-prompts.md), [obligation ledgers](reconstruction-obligation-ledgers.md) | For development, use [testing](testing.md), [client verification](client-verification.md), [tool design](tool-design.md), [admission limits and recovery](design/limits-and-recovery.md), diff --git a/docs/installation.md b/docs/installation.md index 041df39f82..3164457330 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -378,18 +378,32 @@ a particular caller-supplied input will be valid. See Select the part you want to check: -| Task | Readiness check | -| ----------------------------------- | ------------------------------------------------------------------------ | -| Static JavaScript/Electron analysis | Run `rea analyze-javascript-application PATH --json` directly. | -| One analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`, `kuna`) | -| One agent registration | `rea doctor --client codex --json` (see [client IDs](#supported-agents)) | -| Installed workflow instructions | `rea doctor --skill --json` | +| Task | Readiness check | +| ----------------------------------- | -------------------------------------------------------------------------- | +| Static JavaScript/Electron analysis | Run `rea analyze-javascript-application PATH --json` directly. | +| One analysis engine | `rea doctor --provider ghidra --json` (or `hopper`, `ida`, `jadx`, `kuna`) | +| One agent registration | `rea doctor --client codex --json` (see [client IDs](#supported-agents)) | +| Installed workflow instructions | `rea doctor --skill --json` | A scoped report has `scope.mode: "explicit"`. Its `scope_checks` determine `healthy` and the exit status. Other checks appear in `informational_checks`; `environment_healthy` summarizes the full audit. `--target` adds a target check, but the report remains audit-wide unless a scope option is also supplied. +Provider checks read the terminal running doctor. A client-started server may +receive a different environment, and doctor cannot observe it. When an available +provider relies on a setting that a registration's file does not record, or +records with other text, doctor adds a non-blocking +`registration_environment:` check with `classification: "unknown"`. The +setting names appear in that registration's `provider_settings`. Selecting the +client or an affected provider places the check in `scope_checks`; it never +changes `healthy`, because the client may still pass the setting on. Preview +`rea setup --client --skill=false --dry-run` from that terminal and +approve it to record the settings, then restart the client. To see what the +running server can use, call `binary_session` in the client and read +`result.tool_availability`. For Hopper, only an explicit `HOPPER_LAUNCHER_PATH` +is compared. + An invalid `REA_LOG_LEVEL` fails every scope, including one client or the installed skill, because the executable rejects it before any command. A selected client also fails for every other setting that MCP startup rejects, @@ -640,6 +654,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s | Factory Droid | `droid` | | Trae | `trae` | | WorkBuddy | `workbuddy` | +| LM Studio | `lm_studio` | | ZCode | `zcode` | | DeepSeek Harness | `dsh` | | FreeBuff | `freebuff` | @@ -649,6 +664,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s `freebuff` requires `rea-agents@6.8.0` or later. Older releases reject `rea setup --client freebuff` as an unsupported client. +`lm_studio` requires a release after 6.8.0. + See [client configuration and troubleshooting](client-configuration.md) for configuration paths, profiles, trust policies, and schema-limit workarounds. @@ -1146,7 +1163,11 @@ selected formula's `libexec/openjdk.jdk/Contents/Home`, for example Java version bounds declared by your Ghidra installation; REA does not select another JDK when `JAVA_HOME` is explicit or the PATH JVM runs. On macOS, when the PATH launcher cannot start a JVM, it tries executable Homebrew -`opt/openjdk` candidates, preferring the host architecture's prefix. Failed +`opt/openjdk` and installed `opt/openjdk@` candidates, preferring the host +architecture's prefix and the unversioned formula within each prefix. Versioned +aliases are considered in ascending major order within Ghidra's declared Java +bounds; the selected executable still undergoes the normal Java probe and +admission checks. REA does not invoke Homebrew or install a JDK. Failed discovery probes remain available in installation diagnostics. Doctor distinguishes an absent Java command from a command that runs and fails, and retains bounded probe output and any parsed properties when the selected runtime cannot be admitted. @@ -1408,6 +1429,20 @@ in the project root; when omitted it defaults to the selected target's filename. Paths, globs and command flags are not program names. The matching `.rep` directory must be present; restore `.grez` archives with Ghidra first. +For a `.gzf` packed program, first import it into a new Ghidra project in an +existing, caller-selected directory. A packed program is not a `.grez` project +archive: + +```bash +"$GHIDRA_INSTALL_DIR/support/analyzeHeadless" "/absolute/new-project-directory" "new-project" \ + -import "/absolute/program.gzf" -noanalysis +``` + +Select the resulting `new-project.gpr` with `REA_GHIDRA_PROJECT_PATH`. If the +imported program name differs from the selected target's filename, set +`REA_GHIDRA_PROGRAM_NAME` to the name reported by Ghidra's import. REA then +opens its own read-only project copy; it does not import the packed program. + ```bash export REA_ANALYSIS_PROVIDER=ghidra export REA_GHIDRA_PROJECT_PATH=/absolute/projects/app-analysis.gpr @@ -1429,18 +1464,31 @@ the original database; REA reads selected files only for fingerprinting and copy This mode does not copy unrelated sibling projects, rerun repair scripts, or admit bridge database edits. Cleanup removes the owned copy. The bridge checks the program's recorded import SHA-256 against the admitted -target digest; mismatches and project changes fail explicitly. Reopen the target -to resolve a new profile after intentionally changing the source project. +target digest. An unavailable or malformed recorded digest is reported as an +unverified identity, distinct from a known mismatch; neither admits the program +or triggers a fresh import. Select a matching target or explicitly configure a +different project or program. Changes to the source project also fail admission; +reopen the target to resolve a new profile after intentionally changing it. The profile records the caller path, resolved project/name, program and content fingerprint. Original analysis settings and completeness remain **unknown**; prior annotations or edits are caller-project state, not fresh REA discoveries. +For supported header-selected MIPS ELF targets, the profile also binds the +selected target's ELF metadata; it does not claim a fresh-import MIPS lane was +used for the saved project. A matching recorded import digest does not claim that the database is unedited. Saved REA seed/analyzer reports remain historical facts in retained source Evidence; they are not interpreted as overrides applied by this session. Project content participates in snapshot identity, so different project states cannot reuse the same profile's cached observations. +If a function selector is missing, use `list_procedures` or `search_procedures` +to inspect the selected project's observed inventory. A missing entry does not +prove the function is absent from the executable. To explicitly compare with a +fresh import, start another REA process without `REA_GHIDRA_PROJECT_PATH` and +`REA_GHIDRA_PROGRAM_NAME`; the fresh analysis will not include the selected +project's annotations. + To prepare a new project explicitly, use your existing Ghidra installation in a new directory you own (this writes that project; REA does not save it for you): diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 7ec2febebb..1dd3c99d58 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -876,8 +876,22 @@ and Evidence. If that expansion does not fit, the file node retains `static_analysis` and any admitted `semantic_module_analysis` observations, including their exact values and source locations. Use the public modules item view to read them. The graph marks those unexpanded relationships as unknown. -If cumulative result capacity is exhausted, analysis stops; inventoried but -unexamined files remain present with explicit unknown scopes. + +The parent charges everything it retains against the heap that was free when +analysis started, less a transfer reserve. Semantic facts and expanded +relationships stop at half of that capacity, and a source refused there keeps +its static facts with the refusal reported. Static facts can continue to three +quarters of it while the live heap also has room for another copy of what it +holds, so results charged for earlier sources do not cost later sources their +static facts when the heap can still hold them. When that share runs short, +relationships that were reserved but not yet expanded are given up before any +static facts, and each affected file reports the omission. + +If static facts themselves exhaust that share, or the parent's live heap cannot +hold the next checkpoint, analysis stops; inventoried but unexamined files remain +present with explicit unknown scopes. The failure reports the limit that applied +in bytes, what was still available, the live heap in use and what the checkpoint +needed. ### Large local JSON inputs diff --git a/docs/javascript-runtime-reconciliation.md b/docs/javascript-runtime-reconciliation.md index 85bfbff428..16b8fdc1e0 100644 --- a/docs/javascript-runtime-reconciliation.md +++ b/docs/javascript-runtime-reconciliation.md @@ -93,6 +93,10 @@ coverage. It does not print captured source text. Runtime targets, frames, scripts, and workers become capture-scoped graph nodes. REA evaluates compatible static candidates conservatively: +V8 Inspector targets whose protocol type is `node` use JavaScript asset/module +candidates for their verified entry location. A target-only correspondence does +not mark that script loaded; load-state evidence requires a script observation. + 1. A captured-source SHA-256 plus one matching mapped location yields a content-and-location match. 2. When no static candidate exists at a mapped location, one unique exact diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index c6266ce718..d1adc2ca6e 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -1,9 +1,27 @@ # Kuna provider -[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.988, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases, which it reports as unverified in `rea doctor` and in each analysis profile. The verified-release flag compares the reported version; it does not authenticate the executable. The 1.988 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release. +[Kuna](https://github.com/Noelo-Lab/kuna) is an Apache-2.0 decompiler distributed as one `kuna` executable. For a new installation, use Kuna 1.990, REA's recommended verified release. REA uses a `kuna` you supply; it does not download, install, update or bundle Kuna. REA accepts other releases; `rea doctor` and analysis Evidence report them as unverified. Verification compares the reported version; it does not authenticate the executable. Analysis profiles retain the observed version and executable digest, independently of REA's current verified-release policy. The 1.990 real-provider checks ran on macOS arm64; Linux and other hosts have not been verified with that release. Kuna is static: it reads the selected image and never executes it. +This profile change makes earlier Kuna analysis snapshots and resumable directory +exports incompatible, even with the same Kuna executable. Keep the old files: +their recorded Evidence remains historical. To continue analysis, open the +binary without the old snapshot and save a new one, or choose a new export +directory. Existing snapshot Evidence can be extracted from its +`evidence_bundle` JSON member and imported as a separate Evidence bundle; that +does not restore cached workflow entries or resume the old directory export. +For example, using `jq` and a new output path: + +```bash +jq -e '.evidence_bundle | select(type == "object")' old-snapshot.json > history.json +rea evidence-import history.json --json +``` + +The CLI command validates the bundle in a one-shot session. To use the records +in an MCP session, call `import_evidence_bundle` with the extracted file path. +This validates the embedded bundle, not the rest of the snapshot container. + ## Configure ```bash @@ -30,6 +48,10 @@ completes. Cleanup retries keep both the observations and the exact file owner. | `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. | | `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. | +Pseudocode read from a parallel project export includes a prototype-context +limitation in its Evidence, including in function dossiers and composed +workflows. Early single-function results have their own source limitation. + A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance. Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason. @@ -61,7 +83,7 @@ The first text or assembly request in a session looks for this target's export i - Meanwhile, pseudocode comes from one `kuna decompile --addr` run. Name and address lookups, and listings, use the inventory the export takes with `kuna functions --json` before it decompiles. The session runs `kuna functions` itself, under `REA_KUNA_TIMEOUT_MS`, only when no export is building (an inventory-only request does not start one) or the export ends without an inventory. - Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped. - The session keeps each completed single-function text, up to 16 MiB in all with the least recently used dropped first, and answers repeats from it until the export can. After a failed export that lasts for the session. - - Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs. + - Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. REA does not expose per-function records from a failed project export, even if the retained Kuna diagnostic refers to them. Single-function fallback Evidence reports the failed state and this limitation. If cleanup remains incomplete, its error takes precedence and `close_binary` retries the retained resources. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs. - A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function. Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`; omitted [Mach-O data entries](#mach-o-data-entries) are reported in their limitations. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry. @@ -72,7 +94,7 @@ The assembly label index is scanned once per export reader. Cancelling a request Regex procedure and string search uses ECMAScript Unicode matching in a cancellable worker with a matching deadline and a worker old-generation heap. Literal matching remains the default. Cancellation or a matching deadline leaves the session available. The deadline is `REA_REGEX_MATCHING_DEADLINE_MS`, a positive decimal integer from 1 to 2147483647 ms. Unset uses 5000 ms. A larger value is refused because Node schedules a delay past that signed 32-bit bound as 1 ms. Invalid values are configuration errors and do not fall back to the default. The setting is read from the environment that starts REA, so a change needs a restart. A deadline failure names the effective milliseconds, whether they came from the default or the setting, the default, and the maximum. The worker old-generation heap does not raise it. The worker heap request is `REA_REGEX_WORKER_HEAP_REQUEST_MIB`, a decimal integer from 7 to 16384 MiB. Unset uses 128 MiB. A request below 7 MiB aborts the worker during startup, and a larger value is refused. Invalid values are configuration errors and do not fall back to 128. The setting is read from the environment that starts REA, so a change needs a restart. A heap failure names the effective request, whether it came from the default or the setting, the default, the floor, and the maximum. A positive Node.js `--max-old-space-size` on the process that starts REA, including `NODE_OPTIONS`, overrides that request and sets the effective heap. `--max-old-space-size-percentage` takes precedence over that flag in either order. REA does not turn the percentage into a MiB, so the effective heap stays unestablished instead of the worker heap request or the absolute flag. The reported `NODE_OPTIONS` value is the process initial environment when that block can be read. A later edit to the environment is not the value Node applied, and an unreadable initial value leaves the effective heap unestablished. The launch Node options are the initial command line on Linux when that buffer still has a program entry, not a later edit to `process.execArgv`. A preload can clear that array after V8 has applied the flags. Setting `process.title` or `--title` overwrites the command line, so a buffer without a program entry leaves the effective heap unestablished. `--env-file` or `--env-file-if-exists` can supply `NODE_OPTIONS` when the initial environment has none. That file is not part of the initial environment block, so the effective heap stays unestablished instead of the worker heap request. An initial `NODE_OPTIONS` is still used, because Node does not let the file replace it. A positive `--max-heap-size`, including the underscore form and launch `NODE_OPTIONS` on Node versions that allow it, sets the total heap rather than the old generation and can be lower than the worker heap request. The effective old generation stays unestablished while that value is in effect. A zero or empty value clears it. `--experimental-config-file` or `--experimental-default-config-file` can set `nodeOptions`, including a heap percentage, from a file that is not read back. The effective heap stays unestablished when either flag is present, including when an absolute `--max-old-space-size` is also on the command line or in the recovered `NODE_OPTIONS`. Node accepts the config path as `=` or the next argument. -Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger text remains in the local export and the error identifies the file and range. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question. +Single-function subprocess output and each cached source facet share a 16 MiB buffered-text capacity. Cached spans are checked before allocation; larger project-export spans remain in the local export and the error identifies the file and range. A single-function run does not by itself retain a complete project export. `analyze_function` with `detail: "complete"` (the CLI default) returns the complete dossier inline, which can still be large; later retained views do not remove that first transfer. The MCP default retains the dossier and returns its summary, so page the facets you need through `inspect_analysis_view`, or prefer the individual text tools for a focused question. ## Mach-O data entries diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index ce4c7b7510..f440b17321 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -218,6 +218,12 @@ Malformed provider replies return `unreadable_output` for `resolve_containing_procedure` before successful-result projection; cancellation and incomplete cleanup remain errors. +For a procedure match, `procedure_references` with `direction: "incoming"` can +provide the selected provider's reference kinds when supported. Its scope covers +the procedure body, so compare target addresses when selecting references to the +exact entry. It is a separate observation and does not establish runtime use or +complete reference discovery. + REA prepares complete MCP results within the pinned stdio client's 10 MiB receive-buffer budget, including both text and structured representations and room for the JSON-RPC envelope. Tool errors omit `structuredContent` from the @@ -278,6 +284,17 @@ to `/absolute/view-request.json`, then run `rea inspect-analysis-view /absolute/view-request.json --json`. This also reads pre-summary source records without changing their identity; no live provider is started. +Unavailable or inapplicable native collections keep `items.rows: null` and +report `coverage: {"status":"unavailable","examined":0,"total":null,"next_offset":null,"exhausted":false}`. +This applies to unrecorded `native-load-image` checks/sections and inapplicable +`native-context` result limitations. Their total is unknown; no continuation is +offered. Stop paging that collection and inspect the returned unknowns rather +than treating it as an observed empty set or retrying a null offset. Recorded +empty collections, empty selections and offsets past the end retain numeric +totals and exhausted page coverage. Clients upgrading from the earlier +zero-total representation must handle the new `unavailable` status and nullable +total; historical saved views remain valid. + `procedure_address`, `procedure_callees`, `procedure_callers`, `resolve_containing_procedure` and `get_call_graph` also accept `detail: "summary"` (complete remains the default). The summary includes the first 64 recorded @@ -292,6 +309,12 @@ limitation and reason IDs index that page's `items.messages` dictionary. Expand those references to recover exact text, repeated messages, and absent versus empty optional fields. Failed nodes retain their original error. +Retained `xrefs` Evidence also supports `native-observation` address pages and +`native-context` source caveats. `xrefs` still returns complete Evidence by +default and does not accept `detail`; when its MCP response exceeds the budget, +follow the returned continuation to page the retained result. These addresses +remain untyped; paging does not add reference kinds or establish completeness. + These views reference complete source context through `source_context.digest` and caveat counts. Read it using the same parent with `view: {"kind":"native-context","facet":"overview"}`. The other facets are @@ -371,6 +394,12 @@ is null. `total` counts all matching heads; each page preserves complete records in stable unknown ID order. Filters apply before paging. Pages reflect current session state, so keep the unknown ledger unchanged while traversing it. +`list_unknowns` only lists registered heads. Import restores the Unknown records +present in a bundle; it does not turn residual question text embedded in Evidence +into registry entries. An empty registry therefore does not establish that +retained Evidence has no open questions. Inspect the relevant Evidence or its +analysis view for its residual questions. + Automatically retained workflow questions include their immutable result Evidence ID. Distinct results retain independent questions and source links; repeating the same result does not create another unknown. Workflow Evidence @@ -380,6 +409,19 @@ Duplicate creation is a nonretryable `invalid_request`; use the existing head or update it with its current `expected_revision`. A stale update remains a retryable `revision_conflict`. +If automatic question publication fails after a workflow completes, +`details.workflow_unknown_publication` identifies the Evidence and states +whether its retention was confirmed. `created_questions` and +`existing_questions` count processed question occurrences; duplicate questions +may share an Unknown identity. `total_questions` counts string questions, and +`next_question_index` is the zero-based position in the workflow's original +`residual_unknowns` array where publication stopped. It is not a resume token. +For a retained record on the same MCP connection, retrieve the Evidence by ID +with `get_evidence_bundle` and inspect committed heads with `list_unknowns`. +Unprocessed questions remain in the completed Evidence; they were not checked +by this attempt and may already exist from earlier work. A one-shot invocation identifies session-only retention +as `invocation-only` and carries the completed Evidence in its failure when available. + When `record_unknown` or `update_unknown` commits but its complete reply exceeds the response budget, `details.reported_limits.committed_unknown` identifies the exact unknown ID, committed revision and mutation Evidence IDs. Its retained @@ -390,9 +432,26 @@ that revision. If a list response overflows, retry on the same connection with the same filters and offset and a smaller limit; export if one head still cannot fit. +To find missing sources before a downstream analysis refuses them, read +`missing_direct_links` from the summary, or call `get_evidence_bundle` with +`detail: "links"`. The result (`kind: "evidence-link-inspection"`) transfers no +record payload. `filters` select the starting records, while membership is +checked against every record in the session or file, so a filtered-out source is +`retained`, not missing. Rows (`evidence_id`, `linked_evidence_id`, `status`) are +in canonical order, default to `link_status: "missing"` and page with `offset` and +`limit` (default 64); `links` counts every inspected link regardless of that +selection. `traversal: "direct"` (the default) reads the selected records' own +links and sets `retained_closure` to null, because it makes no closure claim. +`traversal: "retained-closure"` also follows retained links, as composed workflows +such as `build_call_path` do, and reports `complete` or `incomplete`; anything +beyond a missing link remains unknown. Missing means absent from this session or +file, not nonexistent, and neither status describes analysis coverage. Importing +a missing source changes this membership without changing any Evidence ID. + For a portable exported bundle, the CLI shares this projection: `rea inspect-evidence-bundle '{"path":"/tmp/evidence.json","detail":"summary","filters":{"operation":"analyze_function"}}' --format json`. -`"detail":"records"` with an `evidence_id` filter returns that complete record. +`"detail":"records"` with an `evidence_id` filter returns that complete record, +and `"detail":"links"` reads link membership. The CLI validates and materializes that local bundle under its existing memory budget; summary delivery does not make arbitrary large files cheap to import. @@ -477,10 +536,12 @@ result's `_meta`, whether the call succeeded or failed: ``` The marker is declared once per tool contract (`returnsTargetContent`) and is -on by default: `export_evidence_bundle`, `record_unknown`, `search_guidance`, -and `read_guidance_section` omit it. The first two contain only REA session -state or caller-supplied values; the guidance tools return REA's own installed -documentation, not text from an analysis target. Tools whose results or errors +on by default: `inspect_analysis_settings`, `update_analysis_settings`, +`export_evidence_bundle`, `record_unknown`, `guided_workflows`, +`search_guidance`, and `read_guidance_section` omit it. The first four contain +only REA session state or caller-supplied values; the workflow and guidance +tools return REA's own installed workflows and documentation, not text from an +analysis target. Tools whose results or errors can include provider, launcher, or device output stay marked, including `binary_session` (provider failure reasons and availability diagnostics), `close_binary` (provider cleanup @@ -633,12 +694,19 @@ refuses the summary before analysis. Further views use the returned `parent_evidence_id` through `inspect_analysis_view`, or a saved Evidence file or portable bundle, and never repeat decompilation. +A null provider observation produces `status: "unavailable"` and +`reason: "no_pseudocode"`; item limitations retain any provider explanation. +This does not establish why pseudocode is absent or whether a retry would help. +Operation failures and malformed non-null output remain `status: "error"`. +The `failed` count includes both unavailable and error items. Existing retained +records keep their original item statuses and error details. + A `batch-decompile` view selects one `collection` and repeats the batch counts and provider identities: - `items`: rows in retained order with their original `index`, so duplicate selectors stay distinct. A row keeps the selector in `address`, its `procedure` - identity and `status`, plus `pseudocode_code_units` or its exact `error` and + identity and `status`, plus `pseudocode_code_units`, an unavailable `reason`, or its exact `error` and `limitations`. `where.indices`, `where.addresses`, `where.procedure_addresses` and `where.statuses` filter exactly and combine; an item with unknown identity never matches `procedure_addresses`. Omitted filters select all, and empty @@ -647,11 +715,11 @@ and provider identities: - `pseudocode`: one `index`'s text in UTF-16 code-unit pages. `offset` and `limit` default to 0 and 16,384, and `coverage` measures that body. An offset inside a surrogate pair is refused, and a limit ending inside one stops before it. An - error item has no text and is refused with a pointer to `items`. + unavailable or error item has no text and is refused with a pointer to `items`. Page `coverage` counts the selection only: exhausting it establishes nothing about functions outside the batch. Evidence `limitations` aggregate every item's -provider observations and are not attributed to one successful item; only error +provider observations and are not attributed to one successful item; unavailable and error items keep their own. A page over the response budget offers a smaller selection from the same `source`: fewer rows, or one body's `pseudocode` pages when a single row does not fit. An inline source offers none, because the record cannot be @@ -921,6 +989,15 @@ concrete version, selection source, and complete analysis profile through choices return typed selection details. A selected provider is never replaced automatically after a runtime failure. +When an admitted `open_binary` attempt fails, `error.details.selection_at_failure` +records the canonical binary path and SHA-256, resource root (including its +caller-selected path), or absence of selection at +the end of that serialized attempt. This is a past observation: a queued or +later operation may already have changed the selection. No selection does not +establish that cleanup is complete. Call `binary_session` for current state +and cleanup status. Validation failures that do not enter the session have no +such receipt. The original error and any cleanup recovery remain authoritative. + `open_binary` resolves an Apple bundle directory (`.app`, `.appex`, `.framework`, `.xpc`, `.systemextension`, `.bundle` or `.plugin`, compared case-insensitively) to the program file its `Info.plist` names in @@ -1539,6 +1616,49 @@ support for omitted historical values. known and differ. If only one value is known, storage comparison is unknown; when both are omitted, storage is not compared. + + +`compare_artifacts` also compares the declarations in the `format_metadata` +of UnityFS members (`unityfs-entry`), PyInstaller entries +(`pyinstaller-entry`), Node SEA members (`node-sea-entry`), +ESP image segments (`esp-image-segment`), DfuSe elements (`dfuse-element`), +ESP flash partitions (`esp-flash-partition`) and FIT images (`fit-image`); +each format's guide names the compared fields. A changed declaration is a +metadata change, including when content is unavailable. A declaration that +only one side records, or that the other side records as another kind, is +unknown and adds a limitation. A field that either side could not read is +unknown, and the other fields still compare. Other kinds are not compared: +their fields record layout, come from the bytes of the artifact or its +container or from caller options that identify the target, or repeat +occurrence fields, except Godot PCK source paths, which +[Godot PCK archives](godot-pck.md) explains. + + + +Every occurrence records `entry_kind`, `executable` and `encrypted`. A +different entry kind, `encrypted` value or occurrence limitation text is a +`metadata` difference, and a different `executable` value is an `executable` +difference. A symlink's own permission bits never mark it `executable`, +because running a symlink runs its target. When either side's content is +unavailable, `compare_artifacts` lists the differences that it can still +observe with `availability`, and the path compares `unknown` unless a +declaration changed. REA does not read symlink targets or encrypted bytes, +so a non-executable file that becomes a symlink compares `unknown` in +`metadata` and `availability`, and so does a member that becomes encrypted. +Declared sizes do not count without content: REA compares observed sizes. +For a node-less metadata record with a compared declaration, only that +declaration, these fields and any recorded ASAR storage decide the +comparison, so a record that becomes encrypted compares `changed` in +`metadata`. Such a record adds `availability` only when a declaration is +unknown. REA does not compare the record's unread payload or its +limitations, which describe the observation. + +When either side has no content, `compare_artifacts` also compares the +occurrences' `declared_integrity` claims, which Godot PCK and ASAR readers +record. Claims of one algorithm that differ add `metadata`. REA cannot verify +them, so they do not make the path `changed`, and equal claims add nothing. +With content on both sides, the observed bytes decide. + POSIX letter-colon names such as `a:b` remain valid logical names; Windows extraction refuses names its filesystem cannot materialize. @@ -1682,7 +1802,9 @@ JSON Schema: - Annotation prose leaves the advertised form: nested property descriptions, literal examples, defaults, and titles are dropped. The root keeps its - description because it states the accepted input groups. + description because it states the accepted input groups. A root `detail` + property keeps its description because it selects between complete Evidence + and a bounded summary. - Schema-local references are inlined within the budget. Kimi Code expands references before sending provider requests, so sharing definitions cannot establish the provider-facing size. Expansion stops at the budget before diff --git a/docs/native-function-matching.md b/docs/native-function-matching.md index 0f3ca743fe..3d669dbd60 100644 --- a/docs/native-function-matching.md +++ b/docs/native-function-matching.md @@ -46,6 +46,18 @@ fingerprint and the same known architecture. Extents are never guessed from the next entry, and relocations are not normalized. A larger mapped read can serve several observed bodies; only needed ranges need to be captured. +For a matched function, its `evidence_links` include the selected byte reads +that covered its body. Request the `function-matches` view with +`collection: "byte-sources"` to page the selected `read_bytes` source IDs, +sides and limitations; intersect those IDs with the function's links. This +distinguishes, for example, Ghidra initialized-memory bytes from file bytes. +Identical overlapping reads retain the selected covering source, not every +corroborating read. Pages preserve the original source limitations and may end +early when the response budget is reached. If one source is too large for a +page, the error identifies its Evidence and the next offset; inspect the +original matching Evidence or export it if retained, then resume at that +offset. Older matching Evidence may not record this source selection. + This first slice reuses existing range observations. Stripped builds can require many lightweight range queries when those facts are not retained yet. Matching does not hide that acquisition cost or silently start those queries. Full diff --git a/docs/native-investigation.md b/docs/native-investigation.md index fe02a2b9d4..f3d5191b8c 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -128,6 +128,16 @@ rea inspect-native-data-type --type /MyStruct --provider ghidra rea inspect-native-data-type --address --provider ghidra ``` +Ghidra bit-field components may have a `type_id` that is not a registered +database type. When `bit_field` is present, `base_type_id` is the provider's +base type and the lookup fields report whether the manager resolves equivalent +types at those exact paths. `bit_size` is the effective width; +`declared_bit_size` can be larger. `bit_offset` is the least-significant-bit +offset within the base type, not a byte-order-independent offset from the start +of the containing structure. Account for the component's storage size and byte +order before applying that offset to a larger word. Missing `bit_field` details +are unreported, not proof that a component is not a bit-field. + Instruction facts include bytes, length, decoder text, ordered register/scalar/ address tokens, typed references and flow destinations. Mid-instruction, data, outside-memory and unlisted addresses have separate outcomes. For Ghidra, @@ -227,7 +237,17 @@ caveats. Failure details retain at most 32 rows within 2,048 JSON-encoded UTF-8 bytes; names may be omitted to keep complete addresses and reasons. The index retains every failure and its complete observed name. Each function's `decompile-index.json` record, whatever its status, keeps the -complete limitations of the observations it used. The returned Evidence keeps +complete limitations of the observations it used. +When available, `provider_source` records the source of that attempt's +pseudocode observation: a file path, SHA-256 and byte range, or a +single-function decompile. The latter describes the method, not a unique run. A file +descriptor records the source as observed; the file may since have changed or +been removed, so compare its current digest before using it. The descriptor +does not authenticate the journal. `status: "unknown"` distinguishes +an unavailable source from an existing matching output whose original producer +is unknown. Older journal records can omit this field; omission is unknown. +Failed attempts retain their source in the append-only journal when the +provider supplied one, even if a later attempt succeeds. The returned Evidence keeps at most eight first-observed distinct per-function provider limitations within an 8,000-byte JSON-encoded UTF-8 text budget and counts omitted observations. Repeated omitted text is counted per observation, not as a distinct caveat. This summary does not retain every omitted string in memory. A fresh output directory must not already exist. `--resume` continues an export for the same target SHA-256, provider, analysis profile, and layout; it requires the directory to exist, and an export without a known @@ -300,6 +320,20 @@ relationships but reports their completeness as unknown. In particular, a resolv tail call observed in one direction does not prove that the reverse inventory is complete. An empty Hopper inventory cannot establish that no such call exists. No whole-binary reverse scan is performed. + +For large native traces, `trace_feature` accepts `detail: "summary"`. It retains +the complete trace and returns counts and its parent Evidence ID. Use +`inspect_analysis_view` with that ID and `view: {kind: "native-feature-trace", +collection: "references", offset: 0, limit: 64}` to page the original rows; +`matches` and `unknowns` are separate collections. Matches accept an exact +`address`; references accept exact `source_address` and `target_address` +filters. Row indices refer to the retained collection, and page coverage does not establish provider discovery +completeness. `list_unknowns` pages the independently managed question heads. +In the one-shot CLI, `rea trace ... --detail summary --evidence_output PATH` +stores the trace and its linked source Evidence for later +`inspect-analysis-view` calls. This bundle does not include the CLI session's +Unknown registry; its original question texts remain available in the trace's +`unknowns` pages. Regex searches use ECMAScript Unicode syntax in a cancellable worker with a matching deadline and a worker old-generation heap. Deadline or cancellation stops matching while leaving the Hopper API available. The deadline is @@ -388,6 +422,11 @@ sites remain distinct. A static route does not establish runtime reachability. Swift/closure dispatch requires resolved static references; unavailable ABI forms remain unknown. +Encoded Objective-C method records retain their own locations in dispatch +Evidence. A handler node uses the implementation address with an unknown file +offset unless that function already has an independently located graph node; +the method-record offset does not locate the handler's code. + Incomplete reference kinds do not discard known typed calls or partial provider call references. A `provider_call` with `observed` resolution retains the provider-reported call site and endpoint without claiming generic direct, @@ -429,6 +468,18 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to 32 MiB. Pagination returns edges whose source is on the node page and preserves stable IDs for endpoints outside that page. +Procedure, node and edge `evidence_id` values identify the source function +observations used by the trace. In an MCP session, retrieve them from the +Evidence bundle for inspection or export. A replayed source keeps the original +observation's identity; the trace also reports that replay occurred. Older +cached value traces are not replayed under the revised workflow profile, while +their historical Evidence remains available. + +The one-shot CLI accepts `--evidence_output ` to save the trace and +the transitive source Evidence closure named by its links. The destination is +never overwritten. `--detail summary` requires `--evidence_output`; the bundle +still retains the complete trace and source Evidence. + Within one value trace, each exact procedure selector is attempted at most once, including failed analyses. A failed seed returns the provider's original typed failure and corrective guidance; it cannot establish a trace or target identity. @@ -439,8 +490,10 @@ resources it owns stops the trace: the result is that provider's own `cleanup_incomplete` failure with its resources and any bound cleanup retry, and the work admitted before the stop is retained as partial Evidence with a located unknown at the stopping read. A successful seed without a native def-use -model still returns its procedure and an explicit coverage unknown. A new trace -can retry failed selectors. +model still returns its procedure and an explicit coverage unknown. An unrecorded +facet stays unknown; when the provider explicitly reports unavailability, the trace +retains its reason. REA's Kuna provider explicitly reports that it does not supply +native def-use observations. A new trace can retry failed selectors. Paging and traversal completeness are separate facts. `page_truncated` means the page omits retained nodes, and the edges they source, before `offset` or after @@ -1004,7 +1057,9 @@ with a path-specific gap and incomplete capture coverage. Null alone does not establish that an accessibility value was successfully read or absent. An initial PID or window mismatch is `invalid_request` on `pid` or -`window_id` and leaves provider availability intact. An unavailable executable +`window_id` and leaves provider availability intact. A selected window that +exists off screen is not a mismatch: selection succeeds and the permission and +capture checks report their own outcome. An unavailable executable URL or unresolved executable path leaves identity unknown and reports `capability_unavailable`. Identity or window loss after the initial capture remains an observation failure with collected facts. @@ -1392,7 +1447,7 @@ procedures. The CLI equivalent is Use `start`/`end` to constrain the inclusive address window, and continue with `next_cursor` until `complete` is true. Inspect `searched` for page coverage and skipped uninitialized blocks. A missing match does not establish absence from -uninitialized memory or across block boundaries. See +uninitialized or excluded memory. See [Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage semantics. This query never executes or modifies target bytes. diff --git a/docs/node-sea.md b/docs/node-sea.md index 33e9188845..85483d8fff 100644 --- a/docs/node-sea.md +++ b/docs/node-sea.md @@ -82,8 +82,12 @@ override. Asset and argument decoding, padding scans, and inventory construction yield in bounded batches and honor caller cancellation. Asset count and name budgets bound retained metadata independently of stored member body sizes. The PE name probe reads headers from the first 64 KiB of the carrier; a PE whose -headers extend beyond that is left to native classification. Snapshot and -code-cache bytes remain opaque. +headers extend beyond that is left to native classification. ELF note tables +are read for at most one note per 12 carrier bytes in total, the most that +disjoint tables can hold, so program headers that repeat the same notes do not +rescan them once per header. Past that count the remaining tables are +unreadable: an ELF without a confirmed blob keeps its native classification, +and one with a blob fails closed. Snapshot and code-cache bytes remain opaque. ## Verification diff --git a/docs/process-capture.md b/docs/process-capture.md index 885606daa8..73a1e825cd 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -358,9 +358,13 @@ sibling filename such as `tool-helper`; selected parent directories still normalize within descendant paths, including when the working directory or an observation path is spelled with a trailing `/` or `/.`. A selected filesystem root `/` also aliases descendant paths, without rewriting URL separators or -HTML closing tags. The comparison contract -records these rules as `path-component-boundaries-v3`, so captures made with the -earlier rules cannot be compared as if the text had the same meaning. +HTML closing tags. When no temporary root applies to an observation, no temporary-root +path is selected: literal `` input and sampled command text +remain unchanged. The comparison contract +records these rules as `path-component-boundaries-v4`, so captures made with the +earlier rules cannot be compared as if the text had the same meaning. Historical +captures remain readable; recapture both sides with the same path normalization +version before comparing them. Rendered frame `lines` hold the visible rows after normalization, with trailing U+0020 spaces removed. Blank rows and internal spaces remain. `columns` records diff --git a/docs/python-packaged-applications.md b/docs/python-packaged-applications.md index 53c0d8c353..d4d91f51bf 100644 --- a/docs/python-packaged-applications.md +++ b/docs/python-packaged-applications.md @@ -77,14 +77,17 @@ table index; original names remain available in the supplied artifact. Path failures use bounded escaped previews and report the original length; accepted paths and retained source names are not shortened. -`compare_artifacts` compares these node-less records as declarations using the -container, container name, source name and type code. Equal option or dependency -declarations can therefore compare unchanged without implying that an external -dependency's bytes were found. For materialized PyInstaller members, the same -fields participate in the metadata delta independently of content identity; -archive offsets and table positions remain location facts, not declaration -identity. Missing declaration facts and unavailable non-metadata content remain -unknown. +`compare_artifacts` compares node-less records, namely runtime options, +dependencies and namespace packages, as declarations using the container, +container name, source name and type code, and whether the record is +encrypted; their limitations are not compared. Equal declarations can +therefore compare unchanged without implying that an external dependency's +bytes were found or that unread legacy namespace payloads match. For +materialized PyInstaller members, the same fields participate in the metadata +delta independently of content identity, including when the member's bytes +are unavailable; archive offsets and table positions remain location facts, +not declaration identity. Missing declaration facts remain unknown, as does +unavailable non-metadata content whose declaration is unchanged. Request `detail: "summary"` for a compact inspection, then query retained Evidence with `inspect_analysis_view`. An inventory view can select diff --git a/docs/readme/README_ar.md b/docs/readme/README_ar.md index 694c093e0c..e4e4c2dd84 100644 --- a/docs/readme/README_ar.md +++ b/docs/readme/README_ar.md @@ -20,6 +20,8 @@ **[الموقع](https://rea.tools/ar/) · [الأدلة](https://rea.tools/ar/guides/) · [دراسات الحالة](https://rea.tools/ar/showcase/)** +انضم إلى مجتمع REA على Discord + [البدء السريع](#البدء-السريع) · [كيف يعمل REA](#كيف-يعمل-rea) · [ما الذي يمكنك تحليله](#ما-الذي-يمكنك-تحليله) · [دراسات الحالة](#دراسات-الحالة) · [إنشاء بيئات التعلم المعزز](#build-rl-environments) · [الأسئلة الشائعة](#الأسئلة-الشائعة) · [أدوات GNU objdump وRizin](../reverse-engineering-tools.md) · [التوثيق](#التوثيق) npx rea-agents setup diff --git a/docs/readme/README_de.md b/docs/readme/README_de.md index d71d77ab73..1301036c8d 100644 --- a/docs/readme/README_de.md +++ b/docs/readme/README_de.md @@ -20,6 +20,8 @@ **[Website](https://rea.tools/de/) · [Anleitungen](https://rea.tools/de/guides/) · [Fallstudien](https://rea.tools/de/showcase/)** +Der REA-Community auf Discord beitreten + [Schnellstart](#schnellstart) · [So funktioniert REA](#so-funktioniert-rea) · [Was du analysieren kannst](#was-du-analysieren-kannst) · [Fallstudien](#fallstudien) · [RL-Umgebungen erstellen](#build-rl-environments) · [Häufige Fragen](#häufige-fragen) · [Dokumentation](#dokumentation) npx rea-agents setup diff --git a/docs/readme/README_el.md b/docs/readme/README_el.md index 6bd0fbc369..e5e450cce2 100644 --- a/docs/readme/README_el.md +++ b/docs/readme/README_el.md @@ -19,6 +19,8 @@ **[Ιστότοπος](https://rea.tools/) · [Οδηγοί](https://rea.tools/guides/) · [Μελέτες περίπτωσης](https://rea.tools/showcase/)** +Έλα στην κοινότητα REA στο Discord + [Γρήγορη εκκίνηση](#γρήγορη-εκκίνηση) · [Πώς λειτουργεί το REA](#πώς-λειτουργεί-το-rea) · [Τι μπορείς να αναλύσεις](#τι-μπορείς-να-αναλύσεις) · [Μελέτες περίπτωσης](#μελέτες-περίπτωσης) · [Δημιουργία περιβαλλόντων RL](#build-rl-environments) · [Συχνές ερωτήσεις](#συχνές-ερωτήσεις) · [Τεκμηρίωση](#τεκμηρίωση) npx rea-agents setup diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md index bcf465c0f5..0d276c8a42 100644 --- a/docs/readme/README_es.md +++ b/docs/readme/README_es.md @@ -20,6 +20,8 @@ **[Sitio web](https://rea.tools/es/) · [Guías](https://rea.tools/es/guides/) · [Casos prácticos](https://rea.tools/es/showcase/)** +Únete a la comunidad de REA en Discord + [Inicio rápido](#inicio-rápido) · [Cómo funciona REA](#cómo-funciona-rea) · [Qué puedes analizar](#qué-puedes-analizar) · [Casos prácticos](#casos-prácticos) · [Crear entornos de RL](#build-rl-environments) · [Preguntas frecuentes](#preguntas-frecuentes) · [Documentación](#documentación) npx rea-agents setup diff --git a/docs/readme/README_fa.md b/docs/readme/README_fa.md index b90329aa87..494317aa50 100644 --- a/docs/readme/README_fa.md +++ b/docs/readme/README_fa.md @@ -20,6 +20,8 @@ **[وب‌سایت](https://rea.tools/fa/) · [راهنماها](https://rea.tools/fa/guides/) · [نمونه‌های عملی](https://rea.tools/fa/showcase/)** +به انجمن REA در دیسکورد بپیوندید + [شروع سریع](#شروع-سریع) · [REA چگونه کار می‌کند؟](#rea-چگونه-کار-می‌کند) · [چه چیزهایی را میتوان تحلیل کرد؟](#چه-چیزهایی-را-می‌توان-تحلیل-کرد) · [نمونه‌های عملی](#نمونه‌های-عملی) · [ساخت محیط‌های RL](#build-rl-environments) · [پرسش‌های متداول](#پرسش‌های-متداول) · [مستندات](#مستندات) npx rea-agents setup diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md index 48832fdb1f..2f165982fe 100644 --- a/docs/readme/README_fr.md +++ b/docs/readme/README_fr.md @@ -20,6 +20,8 @@ **[Site web](https://rea.tools/fr/) · [Guides](https://rea.tools/fr/guides/) · [Études de cas](https://rea.tools/fr/showcase/)** +Rejoignez la communauté REA sur Discord + [Démarrage rapide](#démarrage-rapide) · [Comment fonctionne REA](#comment-fonctionne-rea) · [Ce que vous pouvez analyser](#ce-que-vous-pouvez-analyser) · [Études de cas](#études-de-cas) · [Créer des environnements RL](#build-rl-environments) · [Questions fréquentes](#questions-fréquentes) · [Documentation](#documentation) npx rea-agents setup diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md index 4a994ea102..b283dba9b3 100644 --- a/docs/readme/README_id.md +++ b/docs/readme/README_id.md @@ -20,6 +20,8 @@ **[Situs Web](https://rea.tools/id/) · [Panduan](https://rea.tools/id/guides/) · [Contoh Penggunaan](https://rea.tools/id/showcase/)** +Bergabunglah dengan komunitas REA di Discord + [Mulai Cepat](#mulai-cepat) · [Cara Kerja REA](#cara-kerja-rea) · [Hal yang Dapat Anda Analisis](#hal-yang-dapat-anda-analisis) · [Contoh Penggunaan](#contoh-penggunaan) · [Membangun lingkungan RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentasi](#dokumentasi) npx rea-agents setup diff --git a/docs/readme/README_it.md b/docs/readme/README_it.md index f3f305e22c..ad7f25de50 100644 --- a/docs/readme/README_it.md +++ b/docs/readme/README_it.md @@ -20,6 +20,8 @@ **[Sito web](https://rea.tools/it/) · [Guide](https://rea.tools/it/guides/) · [Showcase](https://rea.tools/it/showcase/)** +Unisciti alla community REA su Discord + [Avvio rapido](#avvio-rapido) · [Come funziona REA](#come-funziona-rea) · [Cosa puoi analizzare](#cosa-puoi-analizzare) · [Showcase](#showcase) · [Creare ambienti RL](#build-rl-environments) · [FAQ](#faq) · [Documentazione](#documentazione) npx rea-agents setup diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md index 31dc75c7c8..1918517ff6 100644 --- a/docs/readme/README_ja.md +++ b/docs/readme/README_ja.md @@ -20,6 +20,8 @@ **[ウェブサイト](https://rea.tools/ja/) · [ガイド](https://rea.tools/ja/guides/) · [事例](https://rea.tools/ja/showcase/)** +DiscordのREAコミュニティに参加 + [クイックスタート](#クイックスタート) · [REA の仕組み](#rea-の仕組み) · [分析できる対象](#分析できる対象) · [事例](#事例) · [RL 環境を構築する](#build-rl-environments) · [よくある質問](#よくある質問) · [ドキュメント](#ドキュメント) npx rea-agents setup diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md index cd420f6b31..4a4755cb04 100644 --- a/docs/readme/README_ko.md +++ b/docs/readme/README_ko.md @@ -20,6 +20,8 @@ **[웹사이트](https://rea.tools/ko/) · [가이드](https://rea.tools/ko/guides/) · [사례](https://rea.tools/ko/showcase/)** +Discord에서 REA 커뮤니티에 참여하세요 + [빠른 시작](#빠른-시작) · [REA의 작동 방식](#rea의-작동-방식) · [분석할 수 있는 대상](#분석할-수-있는-대상) · [사례](#사례) · [RL 환경 만들기](#build-rl-environments) · [자주 묻는 질문](#자주-묻는-질문) · [문서](#문서) npx rea-agents setup diff --git a/docs/readme/README_ml.md b/docs/readme/README_ml.md index c71fa179fc..05e2d110a2 100644 --- a/docs/readme/README_ml.md +++ b/docs/readme/README_ml.md @@ -20,6 +20,8 @@ **[വെബ്സൈറ്റ്](https://rea.tools/) · [ഗൈഡുകൾ](https://rea.tools/guides/) · [ഉദാഹരണങ്ങൾ](https://rea.tools/showcase/)** +Discord-ൽ REA കമ്മ്യൂണിറ്റിയിൽ ചേരുക + [ദ്രുതാരംഭം](#ദ്രുതാരംഭം) · [REA എങ്ങനെ പ്രവർത്തിക്കുന്നു](#rea-എങ്ങനെ-പ്രവർത്തിക്കുന്നു) · [എന്തൊക്കെ വിശകലനം ചെയ്യാം](#എന്തൊക്കെ-വിശകലനം-ചെയ്യാം) · [ഉദാഹരണങ്ങൾ](#ഉദാഹരണങ്ങൾ) · [RL പരിതസ്ഥിതികൾ നിർമ്മിക്കുക](#build-rl-environments) · [പതിവുചോദ്യങ്ങൾ](#പതിവുചോദ്യങ്ങൾ) · [ഡോക്യുമെന്റേഷൻ](#ഡോക്യുമെന്റേഷൻ) npx rea-agents setup diff --git a/docs/readme/README_pl.md b/docs/readme/README_pl.md index 7954c97566..590f32e636 100644 --- a/docs/readme/README_pl.md +++ b/docs/readme/README_pl.md @@ -20,6 +20,8 @@ **[Strona internetowa](https://rea.tools/pl/) · [Przewodniki](https://rea.tools/pl/guides/) · [Przykłady zastosowań](https://rea.tools/pl/showcase/)** +Dołącz do społeczności REA na Discordzie + [Szybki start](#szybki-start) · [Jak działa REA](#jak-działa-rea) · [Co możesz analizować](#co-możesz-analizować) · [Przykłady zastosowań](#przykłady-zastosowań) · [Tworzenie środowisk RL](#build-rl-environments) · [FAQ](#faq) · [Dokumentacja](#dokumentacja) npx rea-agents setup diff --git a/docs/readme/README_pt-BR.md b/docs/readme/README_pt-BR.md index 4be1a409c0..366325f3fc 100644 --- a/docs/readme/README_pt-BR.md +++ b/docs/readme/README_pt-BR.md @@ -20,6 +20,8 @@ **[Site](https://rea.tools/pt-BR/) · [Guias](https://rea.tools/pt-BR/guides/) · [Casos de uso](https://rea.tools/pt-BR/showcase/)** +Participe da comunidade REA no Discord + [Início rápido](#início-rápido) · [Como o REA funciona](#como-o-rea-funciona) · [O que você pode analisar](#o-que-você-pode-analisar) · [Casos de uso](#casos-de-uso) · [Criar ambientes de RL](#build-rl-environments) · [Perguntas frequentes](#perguntas-frequentes) · [Documentação](#documentação) npx rea-agents setup diff --git a/docs/readme/README_ro.md b/docs/readme/README_ro.md index 5755c5b54a..c16fde180d 100644 --- a/docs/readme/README_ro.md +++ b/docs/readme/README_ro.md @@ -20,6 +20,8 @@ **[Site web](https://rea.tools/) · [Ghiduri](https://rea.tools/guides/) · [Studii de caz](https://rea.tools/showcase/)** +Alătură-te comunității REA pe Discord + [Pornire rapidă](#pornire-rapidă) · [Cum funcționează REA](#cum-funcționează-rea) · [Ce poți analiza](#ce-poți-analiza) · [Studii de caz](#studii-de-caz) · [Construiește medii RL](#build-rl-environments) · [Întrebări frecvente](#întrebări-frecvente) · [Documentație](#documentație) npx rea-agents setup diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md index 13e9ba09ad..b873e2cd0f 100644 --- a/docs/readme/README_ru.md +++ b/docs/readme/README_ru.md @@ -20,6 +20,8 @@ **[Сайт](https://rea.tools/ru/) · [Руководства](https://rea.tools/ru/guides/) · [Примеры](https://rea.tools/ru/showcase/)** +Присоединяйтесь к сообществу REA в Discord + [Быстрый старт](#быстрый-старт) · [Как работает REA](#как-работает-rea) · [Что можно анализировать](#что-можно-анализировать) · [Примеры](#примеры) · [Создание RL-окружений](#build-rl-environments) · [Частые вопросы](#частые-вопросы) · [Документация](#документация) npx rea-agents setup diff --git a/docs/readme/README_th.md b/docs/readme/README_th.md index 9f442bfea1..9621f78793 100644 --- a/docs/readme/README_th.md +++ b/docs/readme/README_th.md @@ -20,6 +20,8 @@ **[เว็บไซต์](https://rea.tools/th/) · [คู่มือ](https://rea.tools/th/guides/) · [ตัวอย่างการใช้งาน](https://rea.tools/th/showcase/)** +เข้าร่วมชุมชน REA บน Discord + [เริ่มต้นอย่างรวดเร็ว](#เริ่มต้นอย่างรวดเร็ว) · [REA ทำงานอย่างไร](#rea-ทำงานอย่างไร) · [สิ่งที่วิเคราะห์ได้](#สิ่งที่วิเคราะห์ได้) · [ตัวอย่างการใช้งาน](#ตัวอย่างการใช้งาน) · [สร้างสภาพแวดล้อม RL](#build-rl-environments) · [คำถามที่พบบ่อย](#คำถามที่พบบ่อย) · [เอกสาร](#เอกสาร) npx rea-agents setup diff --git a/docs/readme/README_tr.md b/docs/readme/README_tr.md index 6037f0e2f0..58b79a5428 100644 --- a/docs/readme/README_tr.md +++ b/docs/readme/README_tr.md @@ -20,6 +20,8 @@ **[Web sitesi](https://rea.tools/tr/) · [Rehberler](https://rea.tools/tr/guides/) · [Örnek çalışmalar](https://rea.tools/tr/showcase/)** +Discord'da REA topluluğuna katılın + [Hızlı başlangıç](#hızlı-başlangıç) · [REA nasıl çalışır](#rea-nasıl-çalışır) · [Neleri analiz edebilirsiniz](#neleri-analiz-edebilirsiniz) · [Örnek çalışmalar](#örnek-çalışmalar) · [RL ortamları oluşturun](#build-rl-environments) · [Sık sorulan sorular](#sık-sorulan-sorular) · [GNU objdump ve Rizin araçları](../reverse-engineering-tools.md) · [Belgeler](#belgeler) npx rea-agents setup diff --git a/docs/readme/README_uk.md b/docs/readme/README_uk.md index 937e62dec1..a181184b4b 100644 --- a/docs/readme/README_uk.md +++ b/docs/readme/README_uk.md @@ -20,6 +20,8 @@ **[Сайт](https://rea.tools/uk/) · [Посібники](https://rea.tools/uk/guides/) · [Приклади](https://rea.tools/uk/showcase/)** +Приєднуйтеся до спільноти REA в Discord + [Швидкий старт](#швидкий-старт) · [Як працює REA](#як-працює-rea) · [Що можна аналізувати](#що-можна-аналізувати) · [Приклади](#приклади) · [Створення RL-середовищ](#build-rl-environments) · [Поширені запитання](#поширені-запитання) · [Документація](#документація) npx rea-agents setup diff --git a/docs/readme/README_ur-Latn.md b/docs/readme/README_ur-Latn.md index b8f3c86e56..cff973fdbf 100644 --- a/docs/readme/README_ur-Latn.md +++ b/docs/readme/README_ur-Latn.md @@ -20,6 +20,8 @@ **[Website](https://rea.tools/) · [Guides](https://rea.tools/guides/) · [Showcases](https://rea.tools/showcase/)** +Discord par REA community mein shamil hon + [Jaldi shuru karein](#jaldi-shuru-karein) · [REA kaise kaam karta hai](#rea-kaise-kaam-karta-hai) · [Kya kuch analyze kar sakte hain](#kya-kuch-analyze-kar-sakte-hain) · [Amli misaalein](#amli-misaalein) · [RL environments banayein](#build-rl-environments) · [FAQ](#faq) · [Documentation](#documentation) npx rea-agents setup diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md index bf1e56258c..dd1ed47c8d 100644 --- a/docs/readme/README_vi.md +++ b/docs/readme/README_vi.md @@ -20,6 +20,8 @@ **[Trang web](https://rea.tools/vi/) · [Hướng dẫn](https://rea.tools/vi/guides/) · [Các nghiên cứu điển hình](https://rea.tools/vi/showcase/)** +Tham gia cộng đồng REA trên Discord + [Bắt đầu nhanh](#bắt-đầu-nhanh) · [Cách REA hoạt động](#cách-rea-hoạt-động) · [Những gì bạn có thể phân tích](#những-gì-bạn-có-thể-phân-tích) · [Các nghiên cứu điển hình](#các-nghiên-cứu-điển-hình) · [Xây dựng môi trường RL](#build-rl-environments) · [Câu hỏi thường gặp](#câu-hỏi-thường-gặp) · [Tài liệu](#tài-liệu) npx rea-agents setup diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md index d12645c4ec..a01bb80e28 100644 --- a/docs/readme/README_zh-TW.md +++ b/docs/readme/README_zh-TW.md @@ -20,6 +20,8 @@ **[網站](https://rea.tools/zh-TW/) · [指南](https://rea.tools/zh-TW/guides/) · [案例](https://rea.tools/zh-TW/showcase/)** +加入 REA 的 Discord 社群 + [快速開始](#快速開始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什麼](#可以分析什麼) · [案例](#案例) · [建立 RL 環境](#build-rl-environments) · [常見問題](#常見問題) · [文件](#文件) npx rea-agents setup diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md index cfc29c463d..5a0609472e 100644 --- a/docs/readme/README_zh.md +++ b/docs/readme/README_zh.md @@ -20,6 +20,8 @@ **[网站](https://rea.tools/zh-CN/) · [指南](https://rea.tools/zh-CN/guides/) · [案例](https://rea.tools/zh-CN/showcase/)** +加入 REA 的 Discord 社区 + [快速开始](#快速开始) · [REA 的工作原理](#rea-的工作原理) · [可以分析什么](#可以分析什么) · [案例](#案例) · [构建 RL 环境](#build-rl-environments) · [常见问题](#常见问题) · [GNU objdump 和 Rizin 工具](../reverse-engineering-tools.md) · [文档](#文档) npx rea-agents setup diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 645fef254f..58fb66f0e7 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -14,7 +14,13 @@ can run. ## GNU objdump -`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless provider-capability Evidence describing BFD targets compiled into the installed binary, not a guarantee that a target is supported. +REA sends GNU-style arguments to the selected command but does not verify its +implementation or version. Evidence identifies the command profile as +`objdump.command` and retains the selected executable; it does not claim that +the executable is GNU objdump. Other implementations may accept some commands +while differing in supported options and output. + +`inspect_with_objdump` and `rea inspect-with-objdump` capture stdout and stderr as inline Evidence. Artifact inspection Evidence records SHA-256 digests before and after the provider call and whether those observed digests differ; this cannot prove the exact bytes consumed if the artifact changes during execution. Choose one artifact operation: `file_headers`, `section_headers`, `symbols`, `relocations`, `dwarf`, `disassemble`, or `disassemble_all`. `disassemble` maps to `-d`; `disassemble_all` maps to `-D` and has different symbol-boundary semantics. The pathless `inspect_with_objdump {"operation":"architectures"}` MCP call or `rea list-objdump-architectures` CLI command maps to `-i` and produces subjectless Evidence containing the selected command’s output, not a guarantee that any target is supported. GNU objdump uses this option to list its compiled-in BFD targets. Failure to launch the selected executable reports provider unavailability, the configured command and the host error under `boundary: "provider-launch"`. @@ -25,7 +31,7 @@ their rejected fields in input issues before artifact reads or provider launch. For DWARF, REA does not follow local debug links by default. Set `follow_debug_links` to `true` to pass `-WK`; external debug-file identities are not bound to Evidence when following links is enabled. The CLI flag is `--follow-debug-links`; `--no-follow-debug-links` or `--follow-debug-links=false` keeps the default. A separate `true` or `false` word after the flag is not its value. REA clears `DEBUGINFOD_URLS` in the objdump child environment to disable debuginfod network access on GNU binutils versions that do not support `-WE`. The complete raw output is authoritative; REA does not normalize it into a version-independent disassembly schema. -For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the installed GNU objdump's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed. +For a known native symbol, use `operation: "disassemble", symbol: "my_function"` in MCP or `--symbol my_function` in CLI. REA passes `--disassemble=my_function` to objdump before collecting output, rather than producing the whole disassembly and filtering it afterward. Omit `symbol` to keep full code-section disassembly. Other operations reject this selector. Use a nonempty, NUL-free name with well-formed Unicode; unsupported values are rejected before reading the artifact or starting objdump. Symbol names are passed unchanged; matching, versioned symbols, aliases and the end of the disassembly follow the selected executable's semantics. Evidence records the selector and actual arguments. An empty match does not establish artifact-wide absence, and the selected observation does not contain the omitted disassembly; request the complete operation when needed. ```sh rea inspect-with-objdump ./program disassemble @@ -40,7 +46,7 @@ Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_t ## GDB -Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them. +Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. A single console, target or log record over the 16 MiB MI line limit is dropped and the command is marked truncated, while an oversized result or async record still stops the session. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them. With multiple live inferiors, or a live inferior without a verified TTY, manual close requests an explicit disposition and keeps the session ready. Use diff --git a/docs/specs/agent-environments/fidelity-compare/spec.md b/docs/specs/agent-environments/fidelity-compare/spec.md index 9fe7091413..56fce988eb 100644 --- a/docs/specs/agent-environments/fidelity-compare/spec.md +++ b/docs/specs/agent-environments/fidelity-compare/spec.md @@ -86,7 +86,10 @@ credentials and resets it between sequences. environment refused the call), `not-served`, `no-tool` and `unmapped-id`, each with a JSON pointer and short values. - The report gives agreement overall, per probe and per tool, agreement ignoring item order beside it, the counts per category, the ids mapped and the values normalised, the records left uncompared, and each step's differences (at most - 20 per step). + 20 per step). Each step gives `differences_total` before that cap, `differences_omitted`, and + `differences_truncated`, true when entries were omitted. Equality and categories use all differences. + Omitted entries are not retained and `--full-output` cannot recover them; further investigation uses the original + capture and probe Evidence. ## Non-goals diff --git a/docs/testing-lanes.md b/docs/testing-lanes.md index 4a8dcd65cf..5663dca76b 100644 --- a/docs/testing-lanes.md +++ b/docs/testing-lanes.md @@ -269,8 +269,8 @@ APK for the negative check. No engine or external tool is involved. Record: Flutter Gallery 2.9.2 (112 MB, arm64-v8a / armeabi-v7a / x86_64) — every ABI reports the same agreeing snapshot hash (`65817c30a78bb44c3dc3771876b6010a`, two sections each) read 20 bytes after -the Dart snapshot magic, per-ABI engine GNU build-ids, no labeled Dart -version string (expected for a release engine build), and the Android +the Dart snapshot magic, per-ABI engine GNU build-ids, an unlabeled +Dart-shaped version candidate, and the Android clang/LLVM toolchain line; the AOT inspection locates all four snapshot symbols through the dynamic symbol table on both ELF64 and ELF32 images, validates the data-section headers, agrees with the identification hash, @@ -284,7 +284,7 @@ prefixes, with no private local-file dependency. The verifier now compares compiled CLI and stdio MCP Evidence on every ABI, including data-header kind and declared lengths. Add `--expectations scripts/fixtures/flutter-gallery-expectations.json` for the -documented Gallery APK to assert its pinned digest, hash, exact engine labels +documented Gallery APK to assert its pinned digest, hash, exact engine strings and per-ABI URI counts. The verifier requires complete structural snapshot coverage, reports bounded pool coverage as partial/unverified, and reports exact labels/counts on other APKs unverified without an expectations file. @@ -421,7 +421,10 @@ the first cold pseudocode answer comes from a single-function run or an export that already finished, assembly waits for and serves from that export, and pseudocode and both dossier facets match their recorded file spans and digests, a fresh server reuses the export without rebuilding it, staging is empty after -a completed export, and no owned process survives. Pass +a completed export, and no owned process survives. A separate forced 1 ms +project deadline checks that a timed-out inventory recommends raising the +project deadline without changing jobs; a fresh server retries with 60 seconds +and the same two-worker setting. Pass `--targets DIR --functions a,b` to add caller-built binaries laid out as `DIR//` beside `.stripped`; addresses come from the symbol-bearing build. `--compare-ghidra` with `GHIDRA_INSTALL_DIR` runs those @@ -451,6 +454,16 @@ Mach-O fixture and caller-built symbol-bearing and stripped x86-64 ELF fixtures. Linux and other hosts have not been verified with 1.988; this is not a repeat of the larger 1.833 matrix. +Kuna 1.989 passed the real-provider lane on macOS arm64 with the generated +symbol-bearing and stripped Mach-O fixtures, including cold and warm reads, +references, and cleanup. Linux, other hosts, and the larger 1.833 target matrix +were not verified with 1.989. + +Kuna 1.990 passed the real-provider lane on macOS arm64 with the generated +symbol-bearing and stripped Mach-O fixtures, including cold and warm reads, +references, timeout recovery, and cleanup. Linux, other hosts, and the larger +1.833 target matrix were not verified with 1.990. + ## DOS Ghidra analysis `npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation @@ -1103,13 +1116,13 @@ coverage limits. ### Offline binary layout `npm run verify:binary:layout` requires Linux x64, GCC/binutils, the -`i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and `mipsel-linux-gnu` -cross `gcc`/`readelf` toolchains, absolute +`aarch64-linux-gnu`, `i686-linux-gnu`, `arm-linux-gnueabi`, `mips-linux-gnu` and +`mipsel-linux-gnu` cross `gcc`/`readelf` toolchains, absolute `REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF fixtures and checks public CLI/MCP, lossless addresses/names, file ranges, mitigation inferences, malformed/unsupported input, original file hashes and -released process ownership. Cross-compiled ELF32 executables and relocatable +released process ownership. Cross-compiled ELF64 AArch64 and ELF32 executables and relocatable objects check the reported class, machine and byte order, and every section name, address, offset and size against GNU `readelf`. Exec syscall tracing must identify only the declared Node/Python launchers; no target binary is executed. Core/debugger claims need diff --git a/docs/unityfs.md b/docs/unityfs.md index 963a6b218d..e9564508c9 100644 --- a/docs/unityfs.md +++ b/docs/unityfs.md @@ -10,7 +10,9 @@ strings, declared size, dataflags, blocks-and-directory table bounds, the producer's 16-byte data hash as declared, and the resolved data-area offset. Members (`CAB-*` SerializedFiles, `.resS` resource streams, shared assets) retain declared source paths, virtual stream offsets, sizes and raw node flags. -The directory flag identifies directories. Virtual offsets are not physical +The directory flag identifies directories. `compare_artifacts` compares each +member's declared `source_path` and raw `flags`; its index and offset are +layout. Virtual offsets are not physical carrier locations: member `source_location` is null. Member reads only examine their covering blocks. Uncompressed ranges stream directly; compressed ranges reuse at most one completed decoded block. Independent 64 KiB output chunks diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 1310b2d578..ada3ab10fd 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -97,9 +97,13 @@ it does not invent an empty string or body bytes. The observed Playwright 1.58.2 profile can report a bracketed IPv6 `serverIPAddress`, forwarded from Chromium's `remoteIPAddress` through [Playwright's HAR tracer](https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/server/har/harTracer.ts). +Chromium DevTools exports (declared as `WebInspector` `537.36`, the frozen +AppleWebKit version in Chromium's user agent) report the same bracketed form: +[its HAR writer](https://github.com/ChromeDevTools/devtools-frontend/blob/63739e8ec45ca01b9e4be9b8b05946acdadd8f1f/front_end/models/har/Log.ts) +strips the port but not the brackets. REA preserves the original spelling in reported Evidence and removes brackets -only from its validation copy, only for this declared producer and a valid IPv6 -literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain +only from its validation copy, only for these declared producers and a valid +IPv6 literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain validation failures. Producer declarations do not authenticate origin. Native mitmproxy decoding requires a caller-supplied **mitmdump 12.2.3 on diff --git a/docs/web-runtime.md b/docs/web-runtime.md index abccf9dc55..189d6fc439 100644 --- a/docs/web-runtime.md +++ b/docs/web-runtime.md @@ -22,7 +22,7 @@ Results retain: - Main-document script inventory, exact session script IDs, resource start coordinates, sourceURL/source-map declarations, producer hash and independently computed UTF-8 text digest. - Armed-window requests from the selected main frame and selected origins, complete reported initiator objects (including stack hierarchy/descriptions), derived callsites, exact script-ID associations and unresolved async parent IDs. Source association does not establish UI causality. Callsite and listener `line_number`/`column_number` values are CDP's zero-based lines and UTF-16 columns in the resource named by `url`. With a `script_id`, that is the script's enclosing resource: the script file for an external script, or the HTML document for an inline script. `resource_start` is where the script text starts in that resource. A callsite with an empty `script_id` comes from the request's reported initiator rather than a script call frame, such as markup that the parser read in a document or a module import in a script file. It still locates a position in `url`, but has no session-script association, so it cannot be joined to captured source or `resource_start`. `trace_web_source_location` takes one-based, script-relative lines and zero-based UTF-16 columns. -For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. +For an external script, where `url` is the script file, use `line = line_number + 1` and keep `column = column_number`; this also applies to an empty-`script_id` initiator position in a script file. For an inline script, which requires a `script_id` and its `resource_start`, use `line = line_number - resource_start.line_number + 1`. Subtract `resource_start.column_number` from `column_number` only when `line_number === resource_start.line_number`; keep the reported column on later lines. Columns remain zero-based. This conversion requires known source-start and column coordinates; preserve unavailable coordinates as unknown. Chrome reports a negative start column for some inline scripts, such as one in a helper frame, and positions on that script's first line include it. REA keeps those columns as reported rather than failing the observation; a negative column does not locate a column in `url`, but the subtraction above still gives the script-relative column. The coverage counter interval starts at backend acceptance before the armed receipt and ends at the resetting sample after the local timer. Counts may include execution during these command intervals; request events are restricted to the locally armed window. The result retains both backend timestamps and the distinct local clock. diff --git a/docs/website-module-trace.md b/docs/website-module-trace.md index f5e55d3e07..7612bcdfd1 100644 --- a/docs/website-module-trace.md +++ b/docs/website-module-trace.md @@ -86,8 +86,10 @@ and import map have 32 MiB, 16 MiB and 4 MiB input limits. Native resolution has one 20-second deadline; cleanup may extend that deadline. One renderer is requested, and there is no aggregate host memory-limit claim. Cancellation closes only the owned browser; cleanup failure cannot return a successful trace. -`REA_BROWSER_NO_SANDBOX=true` passes Chromium's corresponding launch option when -the host requires it; it does not enable an REA security boundary. +Chromium keeps its sandbox, except under Termux on Android, which always +launches it without one. On hosts that cannot provide one, such as root in a +container, `REA_BROWSER_NO_SANDBOX=true` launches it without the sandbox; the +variable does not enable an REA security boundary. See [testing](testing.md) for the native loading oracle and CLI/MCP real lane. diff --git a/docs/wxapkg.md b/docs/wxapkg.md new file mode 100644 index 0000000000..e898350d83 --- /dev/null +++ b/docs/wxapkg.md @@ -0,0 +1,84 @@ +# WeChat mini-program wxapkg archives + +`inspect-artifact` and `extract-artifact` read plaintext WeChat mini-program +package (`.wxapkg`) containers without running WeChat or an unpacker. The +plaintext form starts with a `0xBE` mark and an 18-byte big-endian header: +an unknown `info` field producers leave zero, the member-table byte length, +the member-data byte length, a `0xED` mark and the member count. Producers +either count only the name/offset/size rows in that table length or include +the four-byte member count as well; both declarations are accepted. The table +region then carries one name/offset/size row per member; member bytes are +stored verbatim at absolute file offsets. + +Inspection retains the header facts: `info`, index and data lengths, member +count and directory status. Members retain declared source paths, absolute +carrier offsets and declared sizes, so `source_location` reports real file +positions. Member reads only examine their declared range in bounded 64 KiB +chunks. Carrier freshness is checked when a member is opened and after its +stream completes. Reader close cancels and joins outstanding streams before +releasing its descriptor. + +Encrypted PC-client packages start with the `V1MMWX` magic instead of the +plain header. They keep their format identity but report `encrypted` +directory status with unknown coverage: member identities and bytes remain +unknown and no decryption is attempted, so extraction refuses with a typed +error rather than producing ciphertext. Declared tables or data regions +that exceed the file, member extents outside the declared data region, +truncated rows, invalid UTF-8 names, and counts that cannot fit their table +are malformed-input errors. + +## Resource bounds + +These are resource policies, not format maxima. The encoded member table is +limited to 16 MiB and its buffer, parsed rows, retained entries and escaped +path projections share a conservative 64 MiB admission budget, reserved +before allocation; a declared member name is limited to 64 KiB. A reader +admits four active member streams. The shared archive plaintext-work ceiling +is the larger of 256 MiB and 500 times carrier size: every declared member +extent is charged, including aliases that overlap one stored range. A +separate member-I/O budget allows four times the larger of 1,024 and the sum +of the member count and declared 64 KiB plaintext chunks, bounding repeated +tiny-member reads independently of output bytes. + +## CLI + +```sh +rea inspect-artifact app.wxapkg --detail summary \ + --evidence_output evidence/package.json --json +rea extract-artifact app.wxapkg --json +``` + +Detection is byte-based on the `0xBE`/`0xED` marks or the `V1MMWX` magic, so +`.wxapkg`, `__APP__.wxapkg` and extensionless cache files all classify as +`wxapkg` containers. Compact delivery keeps complete Evidence in the +requested bundle. + +## Verification scope + +Synthetic fixtures cover plaintext headers, multiple members, empty members, +chunked reads, unicode and dotted names, malformed marks, truncated tables, +invalid UTF-8, table and directory-budget admission, member extents inside +the header, the index, or beyond the declared data region, a data region +declared past the file, a one-byte member whose table length includes the +member count, an overstated first row consuming its successor, V1MMWX +packages, cancellation inside member reads, paused iteration, close during +I/O, cleanup retry, stream capacity and carrier freshness. Compiled +full/compact MCP tests cover inventory and extraction. + +Real producer verification used plaintext `0xBE` packages from a local macOS +WeChat mini-program cache (`__APP__.wxapkg` main packages and +`__PLUGINCODE__.wxapkg` plugin packages across three distinct app +directories): every member extracted byte-identical to its declared carrier +range, including `__plugin__/`-namespaced members and the producer's +root-prefixed (`/name`) member convention. A cached `V1MMWX` PC package +reports the encrypted directory state with unknown member facts and refuses +extraction. + +`wxml`/`wxss` source recovery, compiled bundle semantics, subpackage +relationships and runtime behavior are outside this container reader's +verification scope. + +Artifact comparison pairs members by their package-relative paths and +compares their bytes. It does not compare `wxapkg` or `wxapkg-entry` +metadata: header fields repeat the package bytes, `index` is layout, and +`source_path` keeps a producer root prefix that the occurrence path strips. diff --git a/patches/release-please+17.3.0.patch b/patches/release-please+17.3.0.patch index 9acd148174..37dbf71d22 100644 --- a/patches/release-please+17.3.0.patch +++ b/patches/release-please+17.3.0.patch @@ -81,6 +81,51 @@ // In this case, there is no squashed merge commit. This could be a simple // merge commit, a rebase merge commit, or a direct commit to the branch. // Fallback to fetching the list of commits from the REST API. In the future +@@ -1154,15 +1154,40 @@ + * @returns {string} HTML URL of the new file + */ + async createFileOnNewBranch(filename, contents, newBranchName, baseBranchName) { +- // create or update new branch to match base branch +- await this.forkBranch(newBranchName, baseBranchName); +- // use the single file upload API ++ // Preserve existing notes until the replacement file is committed. ++ // A failed write must not leave an open proposal pointing at a missing file. ++ if (!(await this.getBranchSha(newBranchName))) { ++ const baseSha = await this.getBranchSha(baseBranchName); ++ if (!baseSha) { ++ throw new Error(`Unable to find base branch: ${baseBranchName}`); ++ } ++ await this.createNewBranch(newBranchName, baseSha); ++ } ++ let existingSha; ++ try { ++ const { data } = await this.octokit.repos.getContent({ ++ owner: this.repository.owner, ++ repo: this.repository.repo, ++ path: filename, ++ ref: newBranchName, ++ }); ++ if (Array.isArray(data) || data.type !== 'file' || !data.sha) { ++ throw new Error(`Expected a file at ${filename} on branch: ${newBranchName}`); ++ } ++ existingSha = data.sha; ++ } ++ catch (error) { ++ if (!(error && error.status === 404)) { ++ throw error; ++ } ++ } + const { data: { content }, } = await this.octokit.repos.createOrUpdateFileContents({ + owner: this.repository.owner, + repo: this.repository.repo, + path: filename, + // contents need to be base64 encoded +- content: Buffer.from(contents, 'binary').toString('base64'), ++ content: Buffer.from(contents, 'utf8').toString('base64'), ++ ...(existingSha ? { sha: existingSha } : {}), + message: 'Saving release notes', + branch: newBranchName, + }); --- a/build/src/github.d.ts +++ b/build/src/github.d.ts @@ -47,6 +47,8 @@ diff --git a/scripts/ci/pr-readiness.d.mts b/scripts/ci/pr-readiness.d.mts index c42684eb1c..7997e9cfdd 100644 --- a/scripts/ci/pr-readiness.d.mts +++ b/scripts/ci/pr-readiness.d.mts @@ -3,6 +3,7 @@ export interface ReadinessPullRequest { readonly state: "open" | "closed"; readonly draft: boolean; readonly body: string | null; + readonly user: { readonly id: number } | null; readonly head: { readonly sha: string }; readonly base: { readonly ref: string }; } @@ -53,7 +54,7 @@ export interface ReadinessGitHub { >; } export function evaluateReadiness( - pull: Pick, + pull: Pick, template: string, ): ReadinessResult; export function runReadinessCheck(input: { diff --git a/scripts/ci/pr-readiness.mjs b/scripts/ci/pr-readiness.mjs index 3f53438156..1043af92ec 100644 --- a/scripts/ci/pr-readiness.mjs +++ b/scripts/ci/pr-readiness.mjs @@ -2,6 +2,14 @@ import { readFile } from "node:fs/promises"; const result = (state, description) => ({ state, description }); +// GitHub account IDs remain stable when a login changes. +const EXEMPT_AUTHOR_IDS = new Set([ + 76467478, // morluto + 208281322, // kaoru0822-kitauji + 161784452, // N0zoM1z0 + 342580529, // gensokyo-dev +]); + const OPEN_HEADS_QUERY = `query ReadinessOpenHeads($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { pullRequests(first: 100, after: $after, states: OPEN, orderBy: {field: CREATED_AT, direction: ASC}) { @@ -105,6 +113,11 @@ export function evaluateReadiness(pull, template) { "pending", "Draft PR: complete Author readiness before requesting review.", ); + if (EXEMPT_AUTHOR_IDS.has(pull.user?.id)) + return result( + "success", + "PR author is exempt from the readiness declaration.", + ); const expected = readinessLines(template).flatMap((line) => { const match = /^- \[ \] (.+)$/u.exec(line); return match ? [match[1]] : []; @@ -210,6 +223,7 @@ export async function runReadinessCheck({ github, context, core }) { latest.head.sha !== pull.head.sha || latest.body !== pull.body || latest.draft !== pull.draft || + latest.user?.id !== pull.user?.id || latest.base.ref !== pull.base.ref ) verdict = result( diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index c0dff3ed66..03bcf518d3 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -76,6 +76,7 @@ const PATH_SCOPES = { "src/frida/frida-bindings.d.ts", "src/objdump/**", "src/cli/reverseEngineeringCommands.ts", + "src/process/ownedDebuggerCleanupError.ts", "src/process/ownedDebuggerSession.ts", "src/rizin/**", "src/contracts/*ToolContracts.ts", @@ -103,6 +104,7 @@ const PATH_SCOPES = { "tests/boundary/providers/RizinInferiorTty.test.ts", "tests/boundary/providers/RizinTestInferiorTty.ts", "tests/boundary/process/ownedDebuggerSession.test.ts", + "tests/boundary/cli/debuggerOneShotExit.test.ts", "tests/boundary/cli/gdbStartupCleanupCli.test.ts", "tests/boundary/cli/reverseEngineeringCommands.test.ts", "tests/boundary/mcp/cutter*.test.ts", @@ -163,6 +165,9 @@ const PATH_SCOPES = { "tests/boundary/mcp/nativeDemangleMcp.test.ts", "tests/boundary/mcp/nativeMachoSummaryMcp.test.ts", "tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts", + "tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts", + "tests/boundary/process/nativeUiVerifierLane.test.ts", + "tests/conformance/native/ui-offscreen.swift", "tests/boundary/cli/nativeSignatureCli.test.ts", "tests/boundary/cli/nativeMachoArm64_32Cli.test.ts", "tests/boundary/cli/nativeMachoEncryptionCli.test.ts", @@ -699,7 +704,9 @@ export const CI_LANES = { agentenv: sameJobs(PATH_SCOPES.agentenv, ["agentenv"]), nativeaot: sameJobs(PATH_SCOPES.nativeaot, ["nativeaot"]), ghidra_bridge: sameJobs(PATH_SCOPES.ghidra_bridge, ["ghidra_bridge"]), - website: sameJobs(PATH_SCOPES.website, ["website"]), + website: sameJobs(PATH_SCOPES.website, ["website"], { + earlyInputs: [".agents/skills/reverse-engineer-anything/**"], + }), }; // An input owner omitted from the registry must never silently lose a lane. for (const [name, paths] of Object.entries(PATH_SCOPES)) diff --git a/scripts/fixtures/flutter-gallery-expectations.json b/scripts/fixtures/flutter-gallery-expectations.json index 909d86e661..9d5e58550a 100644 --- a/scripts/fixtures/flutter-gallery-expectations.json +++ b/scripts/fixtures/flutter-gallery-expectations.json @@ -3,19 +3,19 @@ "snapshot_hash": "65817c30a78bb44c3dc3771876b6010a", "engines": { "x86_64": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] }, "armeabi-v7a": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] }, "arm64-v8a": { - "dart_version": null, + "dart_version": "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", "toolchain_lines": [ "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)" ] diff --git a/scripts/lib/browser-source-map-e2e.mjs b/scripts/lib/browser-source-map-e2e.mjs index 8f9669d40a..504a981b7b 100644 --- a/scripts/lib/browser-source-map-e2e.mjs +++ b/scripts/lib/browser-source-map-e2e.mjs @@ -1,18 +1,23 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../../dist/domain/evidence.js"; import { webSourceLocationResultSchema } from "../../dist/domain/webSourceLocation.js"; +import { PrivateRuntimeRoot } from "../../dist/process/PrivateRuntimeRoot.js"; import { startBrowserSourceMapSite } from "../fixtures/browser-source-map-site.mjs"; import { mcpTextValue } from "./mcp-verifier-results.mjs"; import { withVerifierCleanup } from "./verifier-cleanup.mjs"; +import { createPackageVerifierCommands } from "./verify-package-commands.mjs"; +import { importVerifierSource } from "./verifier-source-loader.mjs"; + +const { BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS } = await importVerifierSource( + new URL("./browser-process-startup.mts", import.meta.url).href, + import.meta.url, +); /** Verify real Chrome capture → export → original compiler point through packaged CLI/MCP. */ export async function verifyBrowserSourceMap( @@ -21,21 +26,35 @@ export async function verifyBrowserSourceMap( ) { let site; let root; + let commands; let transport; let client; + const closeResource = async (owner, resource) => { + if (owner === undefined) return; + try { + await owner.close(); + } catch (cause) { + commands?.adopt(owner, resource); + throw cause; + } + }; return withVerifierCleanup(async () => { site = await startBrowserSourceMapSite(); - root = await mkdtemp(join(tmpdir(), "rea-browser-source-map-")); + root = await PrivateRuntimeRoot.create({ + prefix: "rea-browser-source-map-", + }); + commands = await createPackageVerifierCommands({ workspace: root }); const env = { ...process.env, REA_LOG_LEVEL: "silent", HOPPER_LAUNCHER_PATH: "/rea-unconfigured-provider/hopper", }; const cli = async (...args) => { - const { stdout } = await promisify(execFile)( + const { stdout } = await commands.exec( process.execPath, [entrypoint, ...args, "--json"], - { env, timeout: 45000, maxBuffer: 16 * 1024 * 1024 }, + // Allow the shared cold-start budget plus capture, cleanup and delivery. + { env, timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, ); return parseEvidence(JSON.parse(stdout)); }; @@ -50,6 +69,7 @@ export async function verifyBrowserSourceMap( "capture-browser-scenario", JSON.stringify({ browser: { mode: "launch", executable_path: executable }, + command_timeout_ms: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS, start_url: { url: site.origin }, actions: [ { @@ -66,12 +86,12 @@ export async function verifyBrowserSourceMap( capture: { network: { response_body: true } }, }), ); - const capturePath = join(root, "capture.json"); + const capturePath = join(root.path, "capture.json"); await writeFile(capturePath, JSON.stringify(capture)); const exported = await cli( "export-web-scripts", capturePath, - join(root, "export"), + join(root.path, "export"), ); const manifest = exported.normalized_result; const manifestBytes = await readFile(manifest.manifest.path); @@ -86,7 +106,7 @@ export async function verifyBrowserSourceMap( scriptIndex >= 0, "Actual browser capture omitted compiled source", ); - const mapPath = join(root, "app.js.map"); + const mapPath = join(root.path, "app.js.map"); await writeFile(mapPath, site.map); const before = site.mapRequests(); await client.connect(transport); @@ -132,13 +152,11 @@ export async function verifyBrowserSourceMap( public_point_cases: 4, }; }, [ - () => client?.close(), - () => transport?.close(), - () => site?.close(), - () => - root === undefined - ? undefined - : rm(root, { recursive: true, force: true }), + () => closeResource(client, "source-map MCP client"), + () => closeResource(transport, "source-map stdio transport"), + () => closeResource(site, "source-map fixture site"), + () => commands?.close(), + () => (commands === undefined ? root?.close() : undefined), ]); } function assertPoint(evidence, { site, input, capture, identity }) { diff --git a/scripts/lib/kuna-reference-verifier.mjs b/scripts/lib/kuna-reference-verifier.mjs index ff393f441a..96f1c6ddcc 100644 --- a/scripts/lib/kuna-reference-verifier.mjs +++ b/scripts/lib/kuna-reference-verifier.mjs @@ -114,6 +114,46 @@ export const verifyKunaReferences = async ({ ); } assert.equal(trace.value.truncated, false); + const summary = await call("trace_feature", { + query: name, + detail: "summary", + }); + assert.equal( + summary.value.parent_evidence_id, + trace.structured.evidence_id, + ); + assert.equal( + summary.value.summary.references, + trace.value.references.length, + ); + assert.equal( + summary.value.summary.unknowns, + trace.value.residual_unknowns.length, + ); + for (const [collection, values] of [ + ["matches", trace.value.matches], + ["references", trace.value.references], + ["unknowns", trace.value.residual_unknowns], + ]) { + const view = await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: trace.structured.evidence_id, + }, + view: { + kind: "native-feature-trace", + collection, + offset: 0, + limit: 1, + }, + }); + assert.equal(view.value.coverage.total, values.length); + assert.deepEqual( + view.value.items.rows.map(({ value }) => value), + values.slice(0, 1), + ); + assert.equal(view.value.items.collection, collection); + } } composed = { graph_ms: graph.ms, diff --git a/scripts/lib/native-value-e2e.mjs b/scripts/lib/native-value-e2e.mjs index 88fba60cbf..a5f82569ac 100644 --- a/scripts/lib/native-value-e2e.mjs +++ b/scripts/lib/native-value-e2e.mjs @@ -17,6 +17,7 @@ import { requireMcpOutput, } from "./mcp-verifier-results.mjs"; import { verifyGhidraBoundaries } from "./real-ghidra-boundaries.mjs"; +import { verifyGhidraProjectIdentity } from "./real-ghidra-project-identity.mjs"; const defaultEntrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)); const environment = () => ({ @@ -214,6 +215,19 @@ async function verifyNativeValueAdapters( ); assert.deepEqual(mcpEvidence.normalized_result, mcpGraph); assert.deepEqual(mcpGraph, cliGraph); + const warmReply = await client.callTool( + { + name: "trace_native_values", + arguments: { procedure, max_depth: 2, max_functions: 8, limit: 3 }, + }, + { timeout: 180000 }, + ); + const warmEvidence = parseEvidence( + requireMcpOutput(warmReply, "trace_native_values"), + ); + const warmGraph = nativeValueTraceSchema.parse( + warmEvidence.normalized_result, + ); const analyzed = await client.callTool( { name: "analyze_function", @@ -253,20 +267,9 @@ async function verifyNativeValueAdapters( functionEvidence.analysis_profile, ); assert.deepEqual(functionProfile, upstreamProfile); - const comparison = await client.callTool( - { - name: "compare_functions", - arguments: { - left: analyzed.structuredContent, - right: analyzed.structuredContent, - }, - }, - { timeout: 180000 }, - ); - assert.notEqual(comparison.isError, true, JSON.stringify(comparison)); - assert.notEqual(comparison.structuredContent, undefined); - const comparisonEvidence = parseEvidence( - requireMcpOutput(comparison, "compare_functions"), + const comparisonEvidence = await compareRetainedFunctionEvidence( + client, + functionEvidence, ); assert.equal( comparisonEvidence.normalized_result.status, @@ -286,6 +289,25 @@ async function verifyNativeValueAdapters( "MCP omitted structured Evidence bundle", ); const bundle = requireMcpEvidenceBundle(bundleReply); + for (const [evidence, graph] of [ + [mcpEvidence, mcpGraph], + [warmEvidence, warmGraph], + ]) { + for (const source of [ + ...graph.procedures, + ...graph.nodes, + ...graph.edges, + ]) { + assert.ok(evidence.evidence_links.includes(source.evidence_id)); + assert.equal( + bundle.records.find( + (record) => record.evidence_id === source.evidence_id, + )?.operation, + "analyze_function", + `Missing source Evidence ${source.evidence_id}`, + ); + } + } for (const evidence of [ mcpEvidence, functionEvidence, @@ -328,6 +350,11 @@ async function verifyNativeValueAdapters( await client.close(); await transport.close(); } + const projectIdentity = await verifyGhidraProjectIdentity({ + target, + entrypoint, + env, + }); return { mocked: false, cli: true, @@ -335,6 +362,7 @@ async function verifyNativeValueAdapters( inline_evidence: true, direct_function_comparison: true, boundaries, + project_identity: projectIdentity, nodes: cliGraph.total_nodes, edges: cliGraph.total_edges, decompilations: cliGraph.decompilations, @@ -395,21 +423,27 @@ async function verifyUnresolvedCallEvidence(client) { assert.equal(trace.goal_status, "unknown"); assert.equal(trace.truncated, true); assert.ok(trace.residual_unknowns.length > 0); + const compared = await compareRetainedFunctionEvidence(client, evidence); + assert.equal(compared.normalized_result.status, "unknown"); +} + +/** Compare an already admitted function record without retransmitting its payload. */ +export async function compareRetainedFunctionEvidence(client, evidence) { + const reference = { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }; const compared = await client.callTool( { name: "compare_functions", arguments: { - left: analyzed.structuredContent, - right: analyzed.structuredContent, + left: reference, + right: reference, }, }, { timeout: 180000 }, ); assert.notEqual(compared.isError, true, JSON.stringify(compared)); assert.notEqual(compared.structuredContent, undefined); - assert.equal( - parseEvidence(requireMcpOutput(compared, "compare_functions")) - .normalized_result.status, - "unknown", - ); + return parseEvidence(requireMcpOutput(compared, "compare_functions")); } diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index e6abb17a26..7a6776f837 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -504,6 +504,9 @@ export async function verifyGhidraBoundaries( // Prime both inventories before editing so stale names cannot hide in caches. await call("list_names"); await call("list_procedures"); + const traceInput = { procedure: address, max_depth: 0, max_functions: 1 }; + const traceBefore = await evidence("trace_native_values", traceInput); + const sourceBefore = traceBefore.normalized_result.procedures[0].evidence_id; const changes = { procedure: address, name: "dead", @@ -517,6 +520,16 @@ export async function verifyGhidraBoundaries( comment: changes.comment, inline_comment: changes.inline_comment, }); + const traceAfter = await evidence("trace_native_values", traceInput); + const sourceAfter = traceAfter.normalized_result.procedures[0].evidence_id; + assert.notEqual(sourceBefore, sourceAfter); + const sourceProcedure = (evidenceId) => + call("inspect_analysis_view", { + source: { kind: "retained-evidence", evidence_id: evidenceId }, + view: { kind: "native", facet: "procedure", offset: 0, limit: 1 }, + }); + assert.equal((await sourceProcedure(sourceBefore)).item.name, name); + assert.equal((await sourceProcedure(sourceAfter)).item.name, changes.name); const regexPattern = "^(a|aa)*b$"; const regexFailure = await client.callTool( { diff --git a/scripts/lib/real-ghidra-project-identity.mjs b/scripts/lib/real-ghidra-project-identity.mjs new file mode 100644 index 0000000000..1c068ab2ed --- /dev/null +++ b/scripts/lib/real-ghidra-project-identity.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + appendFile, + copyFile, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { basename, join } from "node:path"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { requireMcpToolError } from "./mcp-verifier-results.mjs"; + +const exec = promisify(execFile); + +/** Verify selected-project identity refusal through real CLI and MCP adapters. */ +export async function verifyGhidraProjectIdentity({ target, entrypoint, env }) { + if (process.platform === "win32") + return { + status: "not-applicable", + reason: "Existing Ghidra projects are not admitted on Windows.", + }; + assert.ok(env.GHIDRA_INSTALL_DIR, "Ghidra installation is required"); + const root = await mkdtemp(join(env.TMPDIR, "rea-project-identity-")); + const targetPath = target.path; + const different = join(root, "different-target"); + const headless = join(env.GHIDRA_INSTALL_DIR, "support", "analyzeHeadless"); + const fixtureEnv = { + ...env, + HOME: root, + XDG_CONFIG_HOME: join(root, "config"), + XDG_CACHE_HOME: join(root, "cache"), + XDG_DATA_HOME: join(root, "data"), + JDK_JAVA_OPTIONS: `-Duser.home="${root}"`, + }; + const program = basename(targetPath); + const known = join(root, "known.gpr"); + const unknown = join(root, "unknown.gpr"); + const runtimeRootsBefore = (await readdir(env.TMPDIR)) + .filter((name) => name.startsWith("rea-ghidra-")) + .sort(); + const sha = async (path) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + try { + await copyFile(targetPath, different); + await appendFile(different, "\nREA_IDENTITY_VERIFIER\n"); + assert.notEqual(await sha(different), await sha(targetPath)); + await writeFile( + join(root, "ClearImportDigest.java"), + `import ghidra.app.script.GhidraScript; +public class ClearImportDigest extends GhidraScript { + @Override public void run() { + currentProgram.setExecutableSHA256(null); + println("REA_DIGEST_AFTER=" + currentProgram.getExecutableSHA256()); + } +}\n`, + ); + await exec( + headless, + [root, "known", "-import", targetPath, "-noanalysis"], + { env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + const cleared = await exec( + headless, + [ + root, + "unknown", + "-import", + targetPath, + "-noanalysis", + "-scriptPath", + root, + "-postScript", + "ClearImportDigest.java", + ], + { env: fixtureEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + assert.match(cleared.stdout, /REA_DIGEST_AFTER=unknown/u); + const before = await projectState([known, unknown, targetPath, different]); + + const knownEnv = { + ...env, + REA_GHIDRA_PROJECT_PATH: known, + REA_GHIDRA_PROGRAM_NAME: program, + }; + const unknownEnv = { + ...env, + REA_GHIDRA_PROJECT_PATH: unknown, + REA_GHIDRA_PROGRAM_NAME: program, + }; + const mismatch = await selectedProjectMcp({ + entrypoint, + env: knownEnv, + first: different, + matching: targetPath, + expected: "import_identity_mismatch", + }); + const unavailable = await selectedProjectMcp({ + entrypoint, + env: unknownEnv, + first: targetPath, + expected: "import_identity_unavailable", + }); + for (const [label, selectedEnv, input, expected] of [ + ["mismatch", knownEnv, different, "import_identity_mismatch"], + ["unavailable", unknownEnv, targetPath, "import_identity_unavailable"], + ]) { + let failed; + try { + await exec( + process.execPath, + [ + entrypoint, + "decompile", + input, + "0x0", + "--provider", + "ghidra", + "--format", + "json", + ], + { env: selectedEnv, timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + ); + } catch (cause) { + assert.equal(cause.code, 1, `${label} CLI exit`); + failed = JSON.parse(cause.stdout); + } + assert.ok(failed, `${label} CLI must refuse`); + assert.equal(failed.code, "configuration_invalid"); + assert.equal(failed.details.diagnostics.remote_code, expected); + } + assert.deepEqual( + await projectState([known, unknown, targetPath, different]), + before, + ); + assert.deepEqual( + (await readdir(env.TMPDIR)) + .filter((name) => name.startsWith("rea-ghidra-")) + .sort(), + runtimeRootsBefore, + "Selected-project checks left a Ghidra runtime directory", + ); + return { + status: "verified", + mismatch, + unavailable, + cli_refusals: true, + source_files_unchanged: true, + }; + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function selectedProjectMcp({ + entrypoint, + env, + first, + matching, + expected, +}) { + const client = new Client({ + name: "rea-project-identity-verifier", + version: "1", + }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + try { + await client.connect(transport); + const tools = new Map( + (await client.listTools()).tools.map((tool) => [tool.name, tool]), + ); + const call = (name, arguments_) => + client.callTool( + { name, arguments: arguments_ }, + { timeout: 180000, toolDefinition: tools.get(name) }, + ); + assert.notEqual( + (await call("open_binary", { path: first, provider_id: "ghidra" })) + .isError, + true, + ); + const rejected = requireMcpToolError(await call("list_documents", {})); + assert.equal( + rejected.code, + "configuration_invalid", + JSON.stringify({ + code: rejected.code, + message: rejected.message, + failure_kind: rejected.details?.diagnostics?.failure_kind, + failure_message: rejected.details?.diagnostics?.failure_message, + stdout: rejected.details?.diagnostics?.stdout?.text?.slice(-2500), + stderr: rejected.details?.diagnostics?.stderr?.text?.slice(-1000), + }), + ); + assert.equal(rejected.details.diagnostics.remote_code, expected); + if (matching !== undefined) { + assert.notEqual( + (await call("open_binary", { path: matching, provider_id: "ghidra" })) + .isError, + true, + ); + assert.notEqual((await call("list_documents", {})).isError, true); + } + assert.notEqual((await call("close_binary", {})).isError, true); + return { + refused: expected, + matching_target_reopened: matching !== undefined, + }; + } finally { + await client.close(); + await transport.close(); + } +} + +async function projectState(paths) { + const files = []; + const walk = async (dir) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) files.push(path); + } + }; + for (const path of paths) { + files.push(path); + if (path.endsWith(".gpr")) await walk(`${path.slice(0, -4)}.rep`); + } + files.sort(); + return Promise.all( + files.map(async (path) => [ + path, + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + ]), + ); +} diff --git a/scripts/lib/release-proposal.d.mts b/scripts/lib/release-proposal.d.mts index bd92aaba19..76aaaaa5d3 100644 --- a/scripts/lib/release-proposal.d.mts +++ b/scripts/lib/release-proposal.d.mts @@ -1,5 +1,7 @@ export function applyReleasePleasePatch(directory?: string): Promise; +export function describeReleaseProposalError(error: unknown): string; + export function releaseProposalSearchDepth( run: (command: string, args: string[]) => Promise, sourceSha: string, diff --git a/scripts/lib/release-proposal.mjs b/scripts/lib/release-proposal.mjs index 1bbb709c61..c6b4e5bc2c 100644 --- a/scripts/lib/release-proposal.mjs +++ b/scripts/lib/release-proposal.mjs @@ -13,6 +13,15 @@ const patch = fileURLToPath( new URL("../../patches/release-please+17.3.0.patch", import.meta.url), ); +export function describeReleaseProposalError(error) { + if (!(error instanceof Error)) return String(error); + const description = error.message || error.name || "Unknown error"; + const status = error.status; + return Number.isInteger(status) && status >= 400 && status <= 599 + ? `${description} (HTTP ${status})` + : description; +} + export async function applyReleasePleasePatch( directory = dirname(require.resolve("release-please/package.json")), ) { diff --git a/scripts/prepare-website.py b/scripts/prepare-website.py index cd284d3e47..33881a25e1 100644 --- a/scripts/prepare-website.py +++ b/scripts/prepare-website.py @@ -7,6 +7,7 @@ import xml.etree.ElementTree as ET from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo from website import PageMetadata, page_url +from website_agents import prepare_discovery, prepare_markdown EXAMPLE_FILES = ( @@ -81,6 +82,8 @@ def prepare_assets(site): prepare_sharing_image(site) prepare_sitemap(site) prepare_notes_example(site) + prepare_discovery(Path(__file__).resolve().parent.parent, site) + prepare_markdown(site) if __name__ == "__main__": diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 57e2b02485..a3d24b59e1 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -70,15 +70,8 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { guardClosedStdout(); // Doctor runs outside Incur, so handle its global timing flag here. Keep a // token consumed by --format as a value, even when it looks like a flag. - const outputValues = new Set( - builtinArguments - .splitIncurBuiltinArguments(doctorArguments) - .outputValues.map(({ index }) => index), - ); - const doctorCliArguments = doctorArguments.filter( - (argument, index) => argument !== "--timings" || outputValues.has(index), - ); - const timingRequested = doctorCliArguments.length !== doctorArguments.length; + const { arguments: doctorCliArguments, enabled: timingRequested } = + builtinArguments.splitCliTimingArguments(doctorArguments); const started = performance.now(); try { const { runProductionMcpDoctorCli } = diff --git a/scripts/record-mcp-catalog.mjs b/scripts/record-mcp-catalog.mjs index 9012b50633..c3da9bcfb9 100644 --- a/scripts/record-mcp-catalog.mjs +++ b/scripts/record-mcp-catalog.mjs @@ -15,12 +15,16 @@ const [ { parseConfig }, { silentLogger }, { PROMPT_CONTRACTS }, + { TOOL_CONTRACTS }, + { getEvidenceInputContract }, ] = await Promise.all([ read("server/createServer.js"), read("composition/binary.js"), read("config/parseConfig.js"), read("logger.js"), read("contracts/promptContracts.js"), + read("contracts/toolContracts.js"), + read("domain/evidenceInputContract.js"), ]); const recordCatalog = async (profile) => { // Catalog generation is independent of the host account and user configuration. @@ -71,6 +75,11 @@ const recordCatalog = async (profile) => { instructions: client.getInstructions(), identity: server.catalogIdentity, tools, + evidenceReferenceAlternatives: TOOL_CONTRACTS.filter( + (contract) => + getEvidenceInputContract(contract.inputSchema) + .hasEvidenceReferenceAlternative, + ).map((contract) => contract.name), prompts, }; const encoded = JSON.stringify(catalog); diff --git a/scripts/release-proposal.mjs b/scripts/release-proposal.mjs index 3f85424ef3..78a22c56b4 100644 --- a/scripts/release-proposal.mjs +++ b/scripts/release-proposal.mjs @@ -4,6 +4,7 @@ import { appendFile } from "node:fs/promises"; import { parseArgs, promisify } from "node:util"; import { applyReleasePleasePatch, + describeReleaseProposalError, releaseProposalSearchDepth, } from "./lib/release-proposal.mjs"; @@ -63,7 +64,7 @@ async function main() { await main().catch((error) => { process.stderr.write( - `Release proposal failed: ${error instanceof Error ? error.message : String(error)}\n`, + `Release proposal failed: ${describeReleaseProposalError(error)}\n`, ); process.exitCode = 1; }); diff --git a/scripts/test-website-agents.py b/scripts/test-website-agents.py new file mode 100644 index 0000000000..d5349e1b1a --- /dev/null +++ b/scripts/test-website-agents.py @@ -0,0 +1,109 @@ +"""Check the public build artifacts and Markdown content contract.""" + +import hashlib +import json +from pathlib import Path +import tempfile +import unittest +from zipfile import ZipFile + +from bs4 import BeautifulSoup +import yaml + +from website_agents import prepare_discovery, prepare_markdown + + +ROOT = Path(__file__).resolve().parent.parent + + +class AgentPublishingChecks(unittest.TestCase): + def test_published_skill_digest_covers_every_reference_and_catalogs_resolve(self): + site = ROOT / "website/_site" + index = json.loads((site / ".well-known/agent-skills/index.json").read_text()) + self.assertEqual(index["$schema"], "https://schemas.agentskills.io/discovery/0.2.0/schema.json") + skill, = index["skills"] + self.assertEqual(skill["name"], "reverse-engineer-anything") + self.assertEqual(skill["type"], "archive") + artifact = site / skill["url"].removeprefix("https://rea.tools/") + self.assertEqual(skill["digest"], "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest()) + source = ROOT / ".agents/skills/reverse-engineer-anything" + with ZipFile(artifact) as archive: + expected = {f"{source.name}/{p.relative_to(source).as_posix()}": p.read_bytes() + for p in source.rglob("*.md")} + self.assertEqual(set(archive.namelist()), set(expected)) + for name, data in expected.items(): + self.assertEqual(archive.read(name), data) + manifest = archive.read(f"{source.name}/SKILL.md").decode() + metadata = yaml.safe_load(manifest[4:].split("\n---\n", 1)[0]) + self.assertEqual(skill["description"], metadata["description"]) + catalog = json.loads((site / ".well-known/ai-catalog.json").read_text()) + self.assertEqual(catalog["specVersion"], "1.0") + entry, = catalog["entries"] + self.assertEqual(entry["identifier"], "urn:air:rea.tools:skill:reverse-engineer-anything") + self.assertEqual(entry["url"], skill["url"]) + self.assertNotIn("data", entry) + ard = json.loads((site / ".well-known/ard.json").read_text()) + resource, = ard["entries"] + self.assertEqual(resource["url"], skill["url"]) + self.assertTrue(2 <= len(resource["representativeQueries"]) <= 5) + + def test_reference_changes_invalidate_digest_and_yaml_scalars_are_parsed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / ".agents/skills/reverse-engineer-anything" + source.mkdir(parents=True) + (source / "SKILL.md").write_text( + '---\nname: reverse-engineer-anything\ndescription: >-\n Inspect programs:\n preserve evidence.\n---\n# REA\n' + ) + (source / "references").mkdir() + reference = source / "references/evidence.md" + reference.write_text("First revision") + site = root / "site" + def build(): + prepare_discovery(root, site) + return json.loads((site / ".well-known/agent-skills/index.json").read_text())["skills"][0] + before = build() + self.assertEqual(before["description"], "Inspect programs: preserve evidence.") + self.assertEqual(build(), before) + reference.write_text("Second revision") + self.assertNotEqual(build()["digest"], before["digest"]) + + def test_markdown_preserves_content_and_resources_without_publishing_excluded_pages(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + page = site / "zh-CN/guides/test/index.html" + page.parent.mkdir(parents=True) + page.write_text('''REA: test +
+

Inspect a program

+
  1. Readinput.exeKeep evidence.
+

Another guide

+
const value = a < b ? "x_y" : "&";\n  keepIndent();
+
Evidence

Hidden evidence remains available.

+ Investigation diagram +
''') + fixture = site / "examples/notes-web/index.html" + fixture.parent.mkdir(parents=True) + fixture.write_text("
Target application
") + fallback = site / "zh-CN/fallback/index.html" + fallback.parent.mkdir(parents=True) + fallback.write_text('
English fallback notice.
') + prepare_markdown(site) + markdown = page.with_suffix(".md").read_text() + self.assertIn('# Inspect a program', markdown) + self.assertIn('const value = a < b ? "x_y" : "&";\n keepIndent();', markdown) + self.assertIn("Hidden evidence remains available.", markdown) + self.assertIn("Shortened display paths.", markdown) + self.assertNotIn("Read`input.exe`", markdown) + self.assertIn("https://rea.tools/zh-CN/guides/other/?q=a%2Fb#code", markdown) + self.assertIn("https://rea.tools/assets/diagram.svg", markdown) + for text in ("Outside navigation", "Contents navigation", "Copy code", "doNotPublish"): + self.assertNotIn(text, markdown) + self.assertFalse(fixture.with_suffix(".md").exists()) + self.assertFalse(fallback.with_suffix(".md").exists()) + html = BeautifulSoup(page.read_text(), "html.parser") + self.assertEqual(html.find("link", rel="alternate")["href"], "https://rea.tools/zh-CN/guides/test/index.md") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs index 64a1b430f4..428daf53f5 100644 --- a/scripts/verify-binary-layout.mjs +++ b/scripts/verify-binary-layout.mjs @@ -38,15 +38,16 @@ if ( !isAbsolute(strace ?? "") ) throw new Error( - "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld, strip and the AArch64/i686/ARM/MIPS/MIPSel linux-gnu cross gcc and readelf must be available. No target is executed.", ); const run = createVerifierRun(); const execute = promisify(execFile); -const ELF32_TOOLCHAINS = [ - ["i686-linux-gnu", "EM_386", "little"], - ["arm-linux-gnueabi", "EM_ARM", "little"], - ["mips-linux-gnu", "EM_MIPS", "big"], - ["mipsel-linux-gnu", "EM_MIPS", "little"], +const CROSS_TOOLCHAINS = [ + ["aarch64-linux-gnu", "EM_AARCH64", 64, "little"], + ["i686-linux-gnu", "EM_386", 32, "little"], + ["arm-linux-gnueabi", "EM_ARM", 32, "little"], + ["mips-linux-gnu", "EM_MIPS", 32, "big"], + ["mipsel-linux-gnu", "EM_MIPS", 32, "little"], ]; for (const command of [ "gcc", @@ -54,7 +55,7 @@ for (const command of [ "strip", "/usr/bin/prlimit", strace, - ...ELF32_TOOLCHAINS.flatMap(([prefix]) => [ + ...CROSS_TOOLCHAINS.flatMap(([prefix]) => [ `${prefix}-gcc`, `${prefix}-readelf`, ]), @@ -347,14 +348,15 @@ try { cases++; } } - // ELF32 profiles: cross-compiled, never executed, checked against GNU readelf. + // Cross profiles: cross-compiled, never executed, checked against GNU readelf. for (const [ prefix, machine, + bits, byteOrder, kind, flags, - ] of ELF32_TOOLCHAINS.flatMap((toolchain) => [ + ] of CROSS_TOOLCHAINS.flatMap((toolchain) => [ [...toolchain, "executable", ["-fPIE", "-pie"]], [...toolchain, "relocatable", ["-c", "-fPIC"]], ])) { @@ -423,16 +425,18 @@ try { `readelf relocation count: ${name}`, ); for (const [entryIndex, [, offset, info]] of rows.entries()) { - // ELF32 r_info uses the low byte for type and the upper 24 bits for - // the symbol index. readelf has already decoded the file's byte order. + // ELF32 r_info packs an 8-bit type under a 24-bit symbol index; + // ELF64 packs a 32-bit type under a 32-bit index. readelf has already + // decoded the file's byte order. const decodedInfo = BigInt(`0x${info}`); + const typeBits = bits === 32 ? 8n : 32n; expectedRelocations.push({ section_index: section.index, entry_index: entryIndex, reported_offset: BigInt(`0x${offset}`), - type: Number(decodedInfo & 0xffn), + type: Number(decodedInfo & ((1n << typeBits) - 1n)), symbol_table_index: section.link, - symbol_index: Number(decodedInfo >> 8n), + symbol_index: Number(decodedInfo >> typeBits), }); } } @@ -445,7 +449,7 @@ try { const value = await inspect(mode, path); assert.deepEqual(value.architecture, { machine, - bits: 32, + bits, byte_order: byteOrder, }); assert.equal( @@ -1312,7 +1316,8 @@ try { } } } - unsupported.writeUInt16LE(183, 18); + // EM_RISCV: AArch64 (183) is now an admitted profile. + unsupported.writeUInt16LE(243, 18); const core = Buffer.from(object); core.writeUInt16LE(4, 16); for (const [name, bytes, category] of [ @@ -1337,7 +1342,7 @@ try { "invalid_input", ], ["undersized-program-header", undersizedProgramHeader, "invalid_input"], - ["arm64", unsupported, "unsupported_target"], + ["riscv64", unsupported, "unsupported_target"], ["core", core, "unsupported_target"], ]) { const path = join(root.path, name); diff --git a/scripts/verify-native-ui.mjs b/scripts/verify-native-ui.mjs index 91a9f6a169..b7bfb43536 100644 --- a/scripts/verify-native-ui.mjs +++ b/scripts/verify-native-ui.mjs @@ -245,7 +245,13 @@ try { scope, { environment: process.env }, ); - if (mismatch.ok || !mismatch.error.message.includes("target-mismatch")) + // A selection failure carries its helper code in the issue, not in its message. + if ( + mismatch.ok || + !mismatch.error.issues?.some(({ message }) => + message.startsWith("target-mismatch:"), + ) + ) throw new Error("Real helper did not reject changed target bytes"); report = { ok: true, diff --git a/scripts/verify-real-ghidra-address-context.mjs b/scripts/verify-real-ghidra-address-context.mjs index 3d9cac7d4b..55c6c1f301 100644 --- a/scripts/verify-real-ghidra-address-context.mjs +++ b/scripts/verify-real-ghidra-address-context.mjs @@ -161,6 +161,38 @@ try { fromNumericOffset.normalized_result, fromOffset.normalized_result, ); + const missingProcedure = "rea_missing_procedure_fixture"; + const missingName = await client.callTool( + { + name: "procedure_pseudo_code", + arguments: { procedure: missingProcedure }, + }, + { timeout: 240000 }, + ); + assert.equal(missingName.isError, true); + const missingNameError = JSON.parse( + missingName.content.find((item) => item.type === "text").text, + ).error; + assert.equal(missingNameError.code, "invalid_request"); + assert.match(missingNameError.message, /rea_missing_procedure_fixture/u); + assert.match(missingNameError.remediation.action, /list_procedures/u); + assert.match(missingNameError.remediation.action, /does not prove/u); + assert.equal(missingNameError.recovery, undefined); + const unmappedOffset = await client.callTool( + { + name: "procedure_pseudo_code", + arguments: { procedure: "file-offset:0x1fffffffffffff" }, + }, + { timeout: 240000 }, + ); + assert.equal(unmappedOffset.isError, true); + const unmappedError = JSON.parse( + unmappedOffset.content.find((item) => item.type === "text").text, + ).error; + assert.equal(unmappedError.code, "invalid_request"); + assert.match(unmappedError.message, /no loaded source mapping/u); + assert.match(unmappedError.remediation.action, /inspect_native_load_image/u); + assert.equal(unmappedError.recovery, undefined); const missingAddress = "ram:0xffffffff"; const rejected = await client.callTool( { name: "procedure_pseudo_code", arguments: { procedure: missingAddress } }, diff --git a/scripts/verify-real-ghidra-com.mjs b/scripts/verify-real-ghidra-com.mjs index 8eb48b1669..5d0b18f81c 100644 --- a/scripts/verify-real-ghidra-com.mjs +++ b/scripts/verify-real-ghidra-com.mjs @@ -276,6 +276,67 @@ try { assert.equal(updated.annotations.inline_comment, changes.inline_comment); assert.equal(updated.dossier.procedure.name, changes.name); assert.ok(updated.dossier.pseudocode.includes(changes.name)); + // Exercise the documented recovery through the public MCP catalog on real + // Ghidra, rather than assuming the provider exposes direct comment methods. + const contextCall = async (name, args) => { + const definition = tools.find((tool) => tool.name === name); + assert.ok(definition?.outputSchema, `${name} must be advertised`); + const response = await client.callTool( + { name, arguments: args }, + { timeout: 240000, toolDefinition: definition }, + ); + return requireMcpOutput(response, name); + }; + const context = ( + await contextCall("inspect_address_context", { + address: fixture.entry, + }) + ).result; + assert.equal(context.procedure.state, "available"); + assert.equal(context.procedure.value.found, true); + for (const facet of [context.comment, context.inline_comment]) { + assert.equal(facet.state, "unavailable"); + assert.match( + facet.remediation, + /analyze_function.*parent_evidence_id.*inspect_analysis_view/u, + ); + } + assert.match(context.bookmarks.remediation, /binary_session/u); + const summary = parseEvidence( + await contextCall("analyze_function", { + procedure: context.procedure.value.procedure.address, + }), + ).normalized_result; + const comments = parseEvidence( + await contextCall("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "comments" }, + }), + ).normalized_result; + for (const [kind, text] of [ + ["comment", changes.comment], + ["inline", changes.inline_comment], + ]) + assert.ok( + comments.item.some( + (item) => + item.address === context.procedure.value.query_address && + item.kind === kind && + item.text === text, + ), + ); + const outside = ( + await contextCall("inspect_address_context", { + address: unlistedAddress, + }) + ).result; + assert.equal(outside.procedure.state, "available"); + assert.equal(outside.procedure.value.found, false); + assert.equal(outside.comment.state, "unavailable"); + assert.match(outside.comment.remediation, /binary_session/u); assert.equal( await call("procedure_address", { procedure: changes.name }), fixture.entry, @@ -386,6 +447,7 @@ try { fresh_decompiler: true, clear_comments: true, cli_mcp_parity: true, + address_context_comment_recovery: true, }, context_input_errors_preserved: true, original_unchanged: true, diff --git a/scripts/verify-real-ghidra-function.mjs b/scripts/verify-real-ghidra-function.mjs index 1e4f2c2cf9..c2764e4689 100644 --- a/scripts/verify-real-ghidra-function.mjs +++ b/scripts/verify-real-ghidra-function.mjs @@ -619,7 +619,35 @@ export async function verifyNativeTypeLayout(client, typeNames) { ) ) throw new Error("Nested union or enum layout drifted"); - return { layout, payload, state }; + const bitsSymbol = typeNames.find((item) => + matchesSymbol(item.value, "rea_ghidra_inventory_bits"), + ); + if (bitsSymbol === undefined) + throw new Error("Bit-field fixture symbol unavailable"); + const bits = await functionCall(client, "inspect_native_data_type", { + document: null, + address: bitsSymbol.address, + }); + const low = bits.fields.find((field) => field.name === "low"); + if ( + bits.kind !== "struct" || + low?.bit_size !== 3 || + low.bit_field?.declared_bit_size !== 3 || + low.bit_field.component_type_lookup !== "not-found" || + low.bit_field.base_type_lookup !== "equivalent" + ) + throw new Error(`Bit-field identity unavailable: ${JSON.stringify(bits)}`); + const component = await functionCall(client, "inspect_native_data_type", { + document: null, + type: low.type_id, + }); + const base = await functionCall(client, "inspect_native_data_type", { + document: null, + type: low.bit_field.base_type_id, + }); + if (component.status !== "unavailable" || base.status !== "available") + throw new Error("Bit-field component or base lookup drifted"); + return { layout, payload, state, bits, base }; } export async function verifyRelativeSwitch(client, procedures, entrySize) { diff --git a/scripts/verify-real-ghidra-inventory.mjs b/scripts/verify-real-ghidra-inventory.mjs index d89f726562..61ae20d1af 100644 --- a/scripts/verify-real-ghidra-inventory.mjs +++ b/scripts/verify-real-ghidra-inventory.mjs @@ -46,8 +46,40 @@ async function expectRemoteFailure(client, operation, parameters, code) { return { operation, code }; } -async function verifyBridgeRejections(client) { +async function verifyBridgeRejections(client, names) { + const global = names.find((item) => + matchesSymbol(item.value, "rea_ghidra_inventory_global"), + ); + if (global === undefined) + throw new Error("Ghidra initialized global probe is unavailable"); + const mapping = await call(client, "address_to_file_offset", { + document: null, + address: global.address, + }); + if (!Number.isSafeInteger(mapping.file_offset)) + throw new Error("Ghidra initialized global has no exact file offset"); return { + unknownProcedure: await expectRemoteFailure( + client, + "procedure_address", + { document: null, procedure: "rea_missing_procedure_fixture" }, + "procedure_not_found", + ), + mappedDataOffset: await expectRemoteFailure( + client, + "procedure_pseudo_code", + { + document: null, + procedure: `file-offset:0x${mapping.file_offset.toString(16)}`, + }, + "procedure_not_found", + ), + unmappedFileOffset: await expectRemoteFailure( + client, + "procedure_pseudo_code", + { document: null, procedure: "file-offset:0x1fffffffffffff" }, + "file_offset_unmapped", + ), unknownDocument: await expectRemoteFailure( client, "list_segments", @@ -86,7 +118,7 @@ export async function verifyInventoryOperations({ }); const rejections = - variant === "debug" ? await verifyBridgeRejections(client) : null; + variant === "debug" ? await verifyBridgeRejections(client, names) : null; const stringSearch = await call(client, "search_strings", { pattern: String.raw`^REA_GHIDRA_(?:INVENTORY_ENTRY|LEAF_VALUE)$`, mode: "regex", diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index b2e02568c0..14e794ba3e 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -6,12 +6,13 @@ import { mkdtemp, mkdir, readFile, + readdir, realpath, rm, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/client"; @@ -41,8 +42,10 @@ import { import { requireMcpToolError, requireMcpOperationResult, + requireMcpOutput, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { runOwnedCommand } from "../dist/process/OwnedCommand.js"; // Optional cross-target lane. No tool acquisition and no target execution. const mode = process.argv[2]; @@ -90,6 +93,114 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { if (process.env[setting] !== undefined) env[setting] = process.env[setting]; } +/** Observe an existing project, and verify REA never alters the caller's copy. */ +async function verifyExistingMipsProject(fixture, byteOrder) { + const projectDir = join(workspace, `${byteOrder}-existing-project`); + await mkdir(projectDir); + assert.ok(installation.analyzeHeadlessPath); + await runOwnedCommand( + { + command: installation.analyzeHeadlessPath, + arguments: [ + projectDir, + "ExistingMips", + "-import", + fixture.path, + "-noanalysis", + ], + env, + runId: run.run_id, + expectedCommand: null, + }, + { timeoutMs: 180000, diagnosticBytes: 1024 * 1024 }, + ); + const projectPath = join(projectDir, "ExistingMips.gpr"); + const digestTree = async () => { + const paths = [projectPath]; + const walk = async (dir) => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) paths.push(path); + } + }; + await walk(join(projectDir, "ExistingMips.rep")); + return Promise.all( + paths.sort().map(async (path) => [ + relative(projectDir, path), + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + ]), + ); + }; + const before = await digestTree(); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: { + ...env, + REA_GHIDRA_PROJECT_PATH: projectPath, + REA_GHIDRA_PROGRAM_NAME: basename(fixture.path), + }, + stderr: "pipe", + }); + const client = new Client({ name: "rea-existing-mips-proof", version: "1" }); + try { + await client.connect(transport); + const opened = await client.callTool( + { + name: "open_binary", + arguments: { path: fixture.path, provider_id: "ghidra" }, + }, + { timeout: 180000 }, + ); + requireMcpOperationResult(opened, "open_binary"); + const result = await client.callTool( + { name: "list_procedures", arguments: {} }, + { timeout: 180000 }, + ); + const observation = parseEvidence( + requireMcpOutput(result, "list_procedures"), + ); + assert.ok(Array.isArray(observation.normalized_result)); + const parameters = observation.analysis_profile?.parameters; + assert.equal(parameters?.import_mode, "existing-project-private-copy-v1"); + assert.equal(parameters?.mips_elf?.byte_order, byteOrder); + assert.equal(parameters?.mips_support_lane, undefined); + assert.deepEqual(parameters?.original_analysis, { + completeness: "unknown", + settings: "unknown", + }); + requireMcpOperationResult( + await client.callTool({ name: "close_binary", arguments: {} }), + "close_binary", + ); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + assert.deepEqual( + await digestTree(), + before, + "REA changed the source project", + ); + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + return { + byte_order: byteOrder, + project_unchanged: true, + target_unchanged: true, + }; +} + /** * Typed float signatures must use soft-float o32 storage, and callers must see * the source constants through it rather than FPU inputs. @@ -320,6 +431,10 @@ try { ); } assert.equal(resolved.value.sha256, fixture.sha256); + const existingProject = + !psp && floatAbi === "hard" + ? await verifyExistingMipsProject(fixture, byteOrder) + : undefined; const snapshotPath = join( workspace, `${byteOrder}-${floatAbi}.snapshot.json`, @@ -682,6 +797,9 @@ try { } reports.push({ ...fixture, + ...(existingProject === undefined + ? {} + : { existing_project: existingProject }), independent_reader: independent, effective_language: image.observations.language_id, effective_compiler_spec: image.observations.compiler_spec_id, diff --git a/scripts/verify-real-ghidra-ppc.mjs b/scripts/verify-real-ghidra-ppc.mjs index a8a99d9408..683601352a 100644 --- a/scripts/verify-real-ghidra-ppc.mjs +++ b/scripts/verify-real-ghidra-ppc.mjs @@ -110,6 +110,39 @@ try { // the plain loader would have chosen e500 for this header. const image = await call("inspect_native_load_image"); assertPowerPcLoadedImage(image.observations, fixture.sha256); + const names = await call("list_names"); + const bitsName = names.find((item) => item.value === "rea_ppc_bits"); + assert.ok(bitsName, "Big-endian bit-field fixture is missing"); + const bitsBytes = await call("read_bytes", { + address: bitsName.address, + length: 4, + }); + assert.equal(bitsBytes.bytes_hex, "22000000"); + const bits = await call("inspect_native_data_type", { + address: bitsName.address, + }); + assert.equal(bits.kind, "struct"); + const bitFields = bits.fields.filter((field) => field.bit_field); + assert.deepEqual( + bitFields.map((field) => [ + field.name, + field.bit_size, + field.bit_offset, + ]), + [ + ["first", 3, 5], + ["second", 5, 0], + ], + ); + for (const field of bitFields) { + assert.equal(field.bit_field.component_type_lookup, "not-found"); + assert.equal(field.bit_field.base_type_lookup, "equivalent"); + assert.equal(field.bit_field.declared_bit_size, field.bit_size); + const base = await call("inspect_native_data_type", { + type: field.bit_field.base_type_id, + }); + assert.equal(base.status, "available"); + } const procedures = await call("list_procedures"); const entry = procedures.find((p) => p.value === "rea_ppc_entry"); const leaf = procedures.find((p) => p.value === "rea_ppc_leaf"); @@ -245,6 +278,7 @@ try { "fixed instruction bytes and immediate", "conditional branch destination", "global word and marker bytes", + "big-endian bit-field storage and base type handoff", "CLI/MCP and snapshot identity", ], procedures: procedures.length, diff --git a/scripts/verify-real-ghidra-raw.mjs b/scripts/verify-real-ghidra-raw.mjs index 1b09cb60c1..4949b57976 100644 --- a/scripts/verify-real-ghidra-raw.mjs +++ b/scripts/verify-real-ghidra-raw.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,12 +18,18 @@ import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { parseEvidence } from "../dist/domain/evidence.js"; +import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../dist/process/ProviderProcess.js"; import { inspectVerifierGhidraInstallation } from "./lib/ghidra-installation-inspection.mjs"; import { requireMcpLifecycleResult, requireMcpOutput, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs"; if (process.argv.length !== 2) throw new Error("Usage: node scripts/verify-real-ghidra-raw.mjs"); @@ -39,6 +52,7 @@ assert.equal(installation.status, "available", JSON.stringify(installation)); const run = createVerifierRun(); const workspace = await mkdtemp(join(tmpdir(), "rea-raw-proof-")); +let fixtureCleaned = true; const runtime = join(workspace, "runtime"); await mkdir(runtime); const bytes = Buffer.from("1eff2fe1", "hex"); // ARM little-endian bx lr. @@ -53,6 +67,17 @@ const env = { REA_ANALYSIS_PROVIDER: "ghidra", REA_PROCESS_RUN_ID: run.run_id, GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR, + ...(process.env.REA_MCP_INPUT_SCHEMA_PROFILE === undefined + ? {} + : { + REA_MCP_INPUT_SCHEMA_PROFILE: process.env.REA_MCP_INPUT_SCHEMA_PROFILE, + }), + ...(process.env.REA_MCP_OUTPUT_SCHEMA_PROFILE === undefined + ? {} + : { + REA_MCP_OUTPUT_SCHEMA_PROFILE: + process.env.REA_MCP_OUTPUT_SCHEMA_PROFILE, + }), ...(process.env.JAVA_HOME === undefined ? {} : { JAVA_HOME: process.env.JAVA_HOME }), @@ -78,8 +103,13 @@ transport.stderr?.on("data", (chunk) => { stderr = (stderr + chunk.toString()).slice(-65536); }); let report; +let searchDefinition; try { await client.connect(transport); + searchDefinition = (await client.listTools()).tools.find( + (tool) => tool.name === "search_bytes", + ); + assert.ok(searchDefinition?.outputSchema); const profiles = []; const sessions = []; for (const entryPoint of ["0x08000000", "0x8000000", "0x09000000"]) { @@ -300,6 +330,198 @@ try { assert.equal(wrongCursor.isError, true); await call("close_binary"); assert.deepEqual(await readFile(repeatedPath), repeatedBytes); + + // Two adjacent virtual regions backed by disjoint file ranges. The same + // signature crosses their boundary and overlaps at every possible start. + const splitPath = join(workspace, "split.elf"); + const splitBytes = splitSearchElf(0); + const splitSha256 = createHash("sha256").update(splitBytes).digest("hex"); + await writeFile(splitPath, splitBytes); + await call("open_binary", { path: splitPath, provider_id: "ghidra" }); + let cursorReopened = false; + for (const pattern of ["aa aa aa", "aa ?? aa"]) { + let cursor; + const addresses = []; + const pages = []; + do { + const evidence = await call( + "search_bytes", + { + pattern, + start: "0x401000", + end: "0x401007", + limit: 2, + ...(cursor === undefined ? {} : { cursor }), + }, + splitSha256, + "elf", + ); + const result = evidence.normalized_result; + addresses.push(...result.matches.map((match) => match.address)); + pages.push({ + matches: result.matches.map(({ address, segment, file_offset }) => ({ + address, + segment, + file_offset, + })), + blocks: result.searched.blocks, + }); + cursor = result.next_cursor ?? undefined; + if (cursor !== undefined && !cursorReopened) { + const snapshot = join(workspace, "split-snapshot.json"); + await call("close_binary", { snapshot_path: snapshot }); + await call("open_binary", { + path: splitPath, + provider_id: "ghidra", + snapshot_path: snapshot, + }); + cursorReopened = true; + } + } while (cursor !== undefined); + assert.deepEqual(addresses, [ + "0x401000", + "0x401001", + "0x401002", + "0x401003", + "0x401004", + "0x401005", + ]); + assert.deepEqual(pages, [ + { + matches: [ + { address: "0x401000", segment: "segment_0", file_offset: 0x1000 }, + { address: "0x401001", segment: "segment_0", file_offset: 0x1001 }, + ], + blocks: [ + { + segment: "segment_0", + start: "0x401000", + end: "0x401003", + bytes: 4, + }, + { + segment: "segment_1", + start: "0x401004", + end: "0x401004", + bytes: 1, + }, + ], + }, + { + matches: [ + { address: "0x401002", segment: "segment_0", file_offset: 0x1002 }, + { address: "0x401003", segment: "segment_0", file_offset: 0x1003 }, + ], + blocks: [ + { + segment: "segment_0", + start: "0x401002", + end: "0x401003", + bytes: 2, + }, + { + segment: "segment_1", + start: "0x401004", + end: "0x401006", + bytes: 3, + }, + ], + }, + { + matches: [ + { address: "0x401004", segment: "segment_1", file_offset: 0x3000 }, + { address: "0x401005", segment: "segment_1", file_offset: 0x3001 }, + ], + blocks: [ + { + segment: "segment_1", + start: "0x401004", + end: "0x401007", + bytes: 4, + }, + ], + }, + ]); + const bounded = await call( + "search_bytes", + { + pattern, + start: "0x401000", + end: "0x401005", + limit: 20, + }, + splitSha256, + "elf", + ); + assert.deepEqual( + bounded.normalized_result.matches.map((match) => match.address), + addresses.slice(0, 4), + ); + } + assert.equal(cursorReopened, true); + const fileStart = await call( + "address_to_file_offset", + { address: "0x401000" }, + splitSha256, + "elf", + ); + const fileSecond = await call( + "address_to_file_offset", + { address: "0x401004" }, + splitSha256, + "elf", + ); + assert.equal(fileStart.normalized_result.file_offset, 0x1000); + assert.equal(fileSecond.normalized_result.file_offset, 0x3000); + await call("close_binary"); + assert.deepEqual(await readFile(splitPath), splitBytes); + await verifyCrossBlockProject(splitPath, splitSha256); + const gapPath = join(workspace, "gap.elf"); + const gapBytes = splitSearchElf(1); + const gapSha256 = createHash("sha256").update(gapBytes).digest("hex"); + await writeFile(gapPath, gapBytes); + await call("open_binary", { path: gapPath, provider_id: "ghidra" }); + const gap = await call( + "search_bytes", + { + pattern: "aa aa aa aa ?? aa aa aa aa", + start: "0x401000", + end: "0x401008", + }, + gapSha256, + "elf", + ); + assert.deepEqual(gap.normalized_result.matches, []); + await call("close_binary"); + assert.deepEqual(await readFile(gapPath), gapBytes); + const zeroFillPath = join(workspace, "initialized-zerofill.elf"); + const zeroFillBytes = splitSearchElf(1, true); + const zeroFillSha256 = createHash("sha256") + .update(zeroFillBytes) + .digest("hex"); + await writeFile(zeroFillPath, zeroFillBytes); + await call("open_binary", { path: zeroFillPath, provider_id: "ghidra" }); + const zeroFill = await call( + "search_bytes", + { + pattern: "aa aa aa aa ?? aa aa aa aa", + start: "0x401000", + end: "0x401008", + }, + zeroFillSha256, + "elf", + ); + // This Ghidra loader initializes the ELF zero-fill byte in this fixture. + assert.deepEqual( + zeroFill.normalized_result.matches.map((match) => match.address), + ["0x401000"], + ); + assert.deepEqual( + zeroFill.normalized_result.searched.skipped_uninitialized, + [], + ); + await call("close_binary"); + assert.deepEqual(await readFile(zeroFillPath), zeroFillBytes); report = { ok: true, mocked: false, @@ -321,6 +543,7 @@ try { original_unchanged: true, x86_targets: x86Targets, byte_search: { + cross_block_paging_and_gap: true, matches: 1005, page_size: 1000, first_search_elapsed_ms: searchElapsedMs, @@ -336,7 +559,7 @@ try { } finally { await client.close(); await transport.close(); - await rm(workspace, { recursive: true, force: true }); + if (fixtureCleaned) await rm(workspace, { recursive: true, force: true }); } const verifierRun = await completeVerifierRun(run); assert.equal(verifierRun.process_lineage.status, "verified"); @@ -349,15 +572,211 @@ process.stdout.write( }) + "\n", ); -async function call(name, args = {}, targetSha256 = sha256) { +async function call( + name, + args = {}, + targetSha256 = sha256, + targetFormat = "raw-binary", +) { const result = await client.callTool( { name, arguments: args }, - { timeout: 240000 }, + { + timeout: 240000, + ...(name === "search_bytes" ? { toolDefinition: searchDefinition } : {}), + }, ); if (["open_binary", "close_binary", "binary_session"].includes(name)) return requireMcpLifecycleResult(result, name); - const evidence = parseEvidence(requireMcpOutput(result, name)); + const output = requireMcpOutput(result, name); + const evidence = parseEvidence(output); assert.equal(evidence.subject.digest.sha256, targetSha256); - assert.equal(evidence.subject.format, "raw-binary"); + assert.equal(evidence.subject.format, targetFormat); return evidence; } + +// Minimal static ELF64 with adjacent (or one-byte-separated) virtual segments +// and noncontiguous file backing. The target is never executed. +function splitSearchElf(gap, zeroFill = false) { + const result = Buffer.alloc(0x3004); + result.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]); + result.writeUInt16LE(2, 16); // ET_EXEC + result.writeUInt16LE(62, 18); // EM_X86_64 + result.writeUInt32LE(1, 20); + result.writeBigUInt64LE(0x401000n, 24); + result.writeBigUInt64LE(64n, 32); + result.writeUInt16LE(64, 52); + result.writeUInt16LE(56, 54); + result.writeUInt16LE(2, 56); + const segment = (offset, fileOffset, address, memorySize = 4) => { + result.writeUInt32LE(1, offset); // PT_LOAD + result.writeUInt32LE(4, offset + 4); // PF_R + result.writeBigUInt64LE(BigInt(fileOffset), offset + 8); + result.writeBigUInt64LE(BigInt(address), offset + 16); + result.writeBigUInt64LE(BigInt(address), offset + 24); + result.writeBigUInt64LE(4n, offset + 32); + result.writeBigUInt64LE(BigInt(memorySize), offset + 40); + result.writeBigUInt64LE(1n, offset + 48); + result.fill(0xaa, fileOffset, fileOffset + 4); + }; + segment(64, 0x1000, 0x401000, zeroFill ? 5 : 4); + segment(120, 0x3000, 0x401004 + gap); + return result; +} + +// Add test-only memory to an owned project, then let REA inspect its private copy. +async function verifyCrossBlockProject(target, targetSha256) { + const projectDirectory = join(workspace, "boundary-project"); + await mkdir(projectDirectory); + const scriptDirectory = fileURLToPath( + new URL("../tests/conformance/ghidra", import.meta.url), + ); + const launch = await spawnOwnedProviderProcess({ + command: installation.analyzeHeadlessPath, + arguments: [ + projectDirectory, + "Boundary", + "-import", + target, + "-noanalysis", + "-scriptPath", + scriptDirectory, + "-postScript", + "ReaCrossBlockFixture.java", + ], + env, + runId: run.run_id, + expectedCommand: null, + }); + fixtureCleaned = false; + const supervisor = new ProviderProcessSupervisor({ + ...launch, + ownsProcessLifetime: true, + cleanup: () => cleanupOwnedProcessGroup(launch.ownership), + }); + await withVerifierCleanup(async () => { + assert.ok(await supervisor.waitForExit(180000), "Fixture setup timed out"); + assert.ok( + await supervisor.waitForOutputClose(1000), + "Fixture output did not close", + ); + const snapshot = supervisor.snapshot(); + const output = `${snapshot.stdout.text}\n${snapshot.stderr.text}`; + assert.equal(snapshot.exitCode, 0, output); + assert.doesNotMatch(output, /REPORT SCRIPT ERROR/u); + }, [ + async () => { + const stopped = await supervisor.stop(); + fixtureCleaned = stopped.status === "verified-cleanup"; + assert.ok( + fixtureCleaned, + `Owned cleanup incomplete; retained ${workspace}: ${JSON.stringify(stopped)}`, + ); + }, + ]); + const beforeProject = await fingerprintFiles(projectDirectory); + const projectTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: { + ...env, + REA_GHIDRA_PROJECT_PATH: join(projectDirectory, "Boundary.gpr"), + }, + stderr: "pipe", + }); + const projectClient = new Client({ + name: "rea-cross-block-project-proof", + version: "1", + }); + try { + await projectClient.connect(projectTransport); + const definition = (await projectClient.listTools()).tools.find( + (tool) => tool.name === "search_bytes", + ); + assert.ok(definition?.outputSchema); + const projectCall = async (name, args) => { + const reply = await projectClient.callTool( + { name, arguments: args }, + { + timeout: 240000, + ...(name === "search_bytes" ? { toolDefinition: definition } : {}), + }, + ); + if (name === "open_binary" || name === "close_binary") + return requireMcpLifecycleResult(reply, name); + const output = requireMcpOutput(reply, name); + const evidence = parseEvidence(output); + assert.equal(evidence.subject.digest.sha256, targetSha256); + return evidence.normalized_result; + }; + await projectCall("open_binary", { path: target, provider_id: "ghidra" }); + const pattern = "de ad ?? ?? be ef"; + const adjacent = await projectCall("search_bytes", { + pattern, + start: "0x60000000", + end: "0x60000005", + }); + assert.deepEqual( + adjacent.matches.map((match) => match.address), + ["0x60000000"], + ); + const gap = await projectCall("search_bytes", { + pattern, + start: "0x60000100", + end: "0x60000105", + }); + assert.deepEqual(gap.matches, []); + assert.ok( + gap.searched.skipped_uninitialized.some( + (block) => block.start === "0x60000102" && block.end === "0x60000103", + ), + ); + const all = await projectCall("search_bytes", { pattern }); + assert.ok(all.matches.some((match) => match.address === "0x60000000")); + assert.ok( + !all.matches.some((match) => match.address.startsWith("overlay_test:")), + ); + assert.ok( + all.searched.skipped_not_loaded.some( + (block) => block.segment === "overlay_test", + ), + ); + const overlay = await projectCall("search_bytes", { + pattern, + start: "overlay_test:0x60000000", + end: "overlay_test:0x60000005", + }); + assert.deepEqual( + overlay.matches.map((match) => match.address), + ["overlay_test:0x60000000"], + ); + await projectCall("close_binary", {}); + } finally { + await projectClient.close(); + await projectTransport.close(); + } + assert.deepEqual(await fingerprintFiles(projectDirectory), beforeProject); +} + +async function fingerprintFiles(directory) { + const result = []; + const visit = async (relative) => { + const entries = await readdir(join(directory, relative), { + withFileTypes: true, + }); + entries.sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const name = join(relative, entry.name); + if (entry.isDirectory()) await visit(name); + else if (entry.isFile()) + result.push([ + name, + createHash("sha256") + .update(await readFile(join(directory, name))) + .digest("hex"), + ]); + else throw new Error(`Unexpected project entry: ${name}`); + } + }; + await visit(""); + return result; +} diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 13659746b8..9c50ce88c9 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,7 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; import { verifyKunaReferences } from "./lib/kuna-reference-verifier.mjs"; import { nativeKunaStrings, @@ -172,7 +175,7 @@ const sampleTree = async (root) => { }; /** One MCP server with a selected provider, sampled while it runs. */ -const openServer = async (provider) => { +const openServer = async (provider, kunaSettings = {}) => { const owner = Symbol(provider.id); pendingServers.add(owner); const transport = new StdioClientTransport({ @@ -185,6 +188,7 @@ const openServer = async (provider) => { REA_PROCESS_RUN_ID: run.run_id, REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot, + ...kunaSettings, ...(process.env.GHIDRA_INSTALL_DIR === undefined ? {} : { GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR }), @@ -229,6 +233,13 @@ const openServer = async (provider) => { structured: result.structuredContent, }; }; + const callError = async (name, arguments_ = {}) => { + const result = await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ); + return requireMcpToolError(result); + }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -270,7 +281,59 @@ const openServer = async (provider) => { // A rejected connection/target still owns its transport and workspace. await close(cause); } - return { call, close }; + return { call, callError, close }; +}; + +/** Verify a forced inventory timeout can be retried without changing jobs. */ +const verifyProjectTimeoutRecovery = async (path, address) => { + const before = createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const settings = { + REA_KUNA_JOBS: "2", + REA_KUNA_TIMEOUT_MS: "60000", + REA_KUNA_PROJECT_TIMEOUT_MS: "1", + }; + const timed = await openServer({ path, id: "kuna" }, settings); + let timedFailure; + try { + const failure = await timed.callError("procedure_assembly", { + procedure: address, + }); + assert.equal(failure.code, "capability_unavailable"); + assert.match(failure.message, /kuna functions/u); + assert.match(failure.remediation.action, /REA_KUNA_PROJECT_TIMEOUT_MS/u); + assert.doesNotMatch(failure.remediation.action, /REA_KUNA_JOBS/u); + } catch (cause) { + timedFailure = cause; + } finally { + await timed.close(timedFailure); + } + const retry = await openServer( + { path, id: "kuna" }, + { ...settings, REA_KUNA_PROJECT_TIMEOUT_MS: "60000" }, + ); + let retryFailure; + try { + const assembly = await retry.call("procedure_assembly", { + procedure: address, + }); + assert.equal(typeof assembly.value, "string"); + assert.ok(assembly.value.length > 0); + assert.equal(assembly.structured.analysis_profile.parameters.jobs, 2); + } catch (cause) { + retryFailure = cause; + } finally { + await retry.close(retryFailure); + } + assert.equal( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + before, + "Kuna timeout recovery changed the target", + ); + return { forced_deadline_ms: 1, retry_deadline_ms: 60000, jobs: 2 }; }; /** Count structuring and diagnostic markers in provider pseudocode. */ @@ -448,6 +511,30 @@ const verifyKuna = async ( references, }); } + if (functions.length > 0) { + const trace = await cold.call("trace_native_values", { + procedure: functions[0][1], + max_depth: 0, + max_functions: 1, + }); + const bundle = await cold.call("get_evidence_bundle", { + detail: "complete", + }); + for (const source of [ + ...trace.value.procedures, + ...trace.value.nodes, + ...trace.value.edges, + ]) { + assert.ok(trace.structured.evidence_links.includes(source.evidence_id)); + assert.equal( + bundle.value.records.find( + (record) => record.evidence_id === source.evidence_id, + )?.operation, + "analyze_function", + `Missing source Evidence ${source.evidence_id}`, + ); + } + } } catch (cause) { coldFailure = cause; } finally { @@ -535,6 +622,7 @@ const verifyVariant = async ({ flavor, directory, symbols }, variant) => { }; const results = []; +let timeoutRecovery; const failures = []; let inspectionResourcesClean = true; try { @@ -596,6 +684,11 @@ try { }), }); + timeoutRecovery = await verifyProjectTimeoutRecovery( + built, + fixtureFunctions.get("rea_checksum"), + ); + // Caller-built matrix. for (const pair of callerPairs) for (const variant of [pair.file, `${pair.file}.stripped`]) @@ -650,6 +743,9 @@ const report = { platform: `${process.platform}/${process.arch}`, verifier_run: lifecycle, results, + ...(timeoutRecovery === undefined + ? {} + : { timeout_recovery: timeoutRecovery }), limitations: [ "Targets are analyzed statically; they are never executed.", "Memory is the sampled peak resident set of the MCP server process tree; CPU is its sampled cumulative time. A short-lived peak between 200 ms samples is not observed.", diff --git a/scripts/website.py b/scripts/website.py index 4891347ff9..b6b3a39f19 100644 --- a/scripts/website.py +++ b/scripts/website.py @@ -327,8 +327,9 @@ def main(): refresh_contributors(root) elif args.command == "check": sync_pages(root, check=True) - for name in ("prepare-website.py", "verify-website.py", "test-website.py"): + for name in ("prepare-website.py", "verify-website.py", "test-website.py", "test-website-agents.py"): run_script(root, name) + subprocess.run(["node", "--test", "website/worker.test.mjs"], cwd=root, check=True) elif args.command == "links": run_script(root, "audit-website-links.py", ["--report", args.report] if args.report else []) else: diff --git a/scripts/website_agents.py b/scripts/website_agents.py new file mode 100644 index 0000000000..74a3e0a5d5 --- /dev/null +++ b/scripts/website_agents.py @@ -0,0 +1,116 @@ +"""Build public agent discovery and Markdown from their maintained sources.""" + +import hashlib +import json +from urllib.parse import urljoin +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + +from bs4 import BeautifulSoup +from markdownify import MarkdownConverter +import yaml + +from website import NOTES_FIXTURES, PageMetadata, SITE_ORIGIN, page_url + + +def write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def prepare_discovery(root, site): + """Publish the complete skill, including its relative reference documents.""" + source = root / ".agents/skills/reverse-engineer-anything" + skill = (source / "SKILL.md").read_text(encoding="utf-8") + if not skill.startswith("---\n") or "\n---\n" not in skill[4:]: + raise ValueError("Public REA skill needs YAML frontmatter") + metadata = yaml.safe_load(skill[4:].split("\n---\n", 1)[0]) + if not isinstance(metadata, dict): + raise ValueError("Public REA skill needs YAML metadata") + name, description = metadata["name"], metadata["description"] + if name != source.name or not isinstance(description, str) or not description.strip(): + raise ValueError("Invalid public REA skill metadata") + discovery = site / ".well-known/agent-skills" + discovery.mkdir(parents=True, exist_ok=True) + artifact = discovery / f"{name}.zip" + with ZipFile(artifact, "w") as archive: + for path in [source / "SKILL.md", *sorted(source.glob("references/**/*.md"))]: + relative = path.relative_to(source) + entry = ZipInfo(f"{name}/{relative.as_posix()}", (1980, 1, 1, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, path.read_bytes(), compress_type=ZIP_DEFLATED) + artifact_url = f"{SITE_ORIGIN}/.well-known/agent-skills/{artifact.name}" + write_json(discovery / "index.json", { + "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", + "skills": [{ + "name": name, "type": "archive", "description": description, + "url": artifact_url, + "digest": "sha256:" + hashlib.sha256(artifact.read_bytes()).hexdigest(), + }], + }) + entry = { + "identifier": f"urn:air:rea.tools:skill:{name}", + "displayName": "REA reverse engineering skill", + "description": description, + "type": "application/zip", + "url": artifact_url, + } + write_json(site / ".well-known/ai-catalog.json", { + "specVersion": "1.0", + "host": {"displayName": "REA", "documentationUrl": f"{SITE_ORIGIN}/get-started/"}, + "entries": [entry], + }) + write_json(site / ".well-known/ard.json", {"entries": [{ + **entry, + "representativeQueries": [ + "Explain how a feature works in a shipped executable using REA.", + "Trace an Electron application's behavior back to its packaged JavaScript.", + "Build an AgentEnv bundle from a web application capture.", + ], + }]}) + print("Prepared agent skill archive, discovery index, AI Catalog and ARD manifest.") + + +def prepare_markdown(site): + """Convert rendered reading pages, keeping code and resolving resource URLs.""" + converter = MarkdownConverter(heading_style="ATX", bullets="-", escape_underscores=False) + count = 0 + for path in sorted(site.rglob("*.html")): + if path.relative_to(site).as_posix() in NOTES_FIXTURES: + continue + source = path.read_text(encoding="utf-8") + metadata = PageMetadata() + metadata.feed(source) + # Drafts and English fallbacks keep their HTML indexing policy intact. + if metadata.noindex: + continue + soup = BeautifulSoup(source, "html.parser") + main = soup.find("main") + if main is None: + continue + # Preserve fallback notices and closed disclosures; remove interface chrome. + for node in main.select('script, style, nav, button, svg, .home-page-nav, [aria-hidden="true"]'): + if node.parent is not None: + node.decompose() + # These spans are separate lines in the shared flow component's CSS. + for node in main.select("span.flow-label, span.flow-node, span.flow-note"): + node.name = "div" + canonical = page_url(site, path) + for node in main.select("[href], [src]"): + for attribute in ("href", "src"): + if node.has_attr(attribute): + node[attribute] = urljoin(canonical, node[attribute]) + title = metadata.titles[0] if metadata.titles else "REA" + header = yaml.safe_dump({"title": title, "url": canonical}, allow_unicode=True, sort_keys=False) + markdown = converter.convert_soup(main).strip() + path.with_suffix(".md").write_text(f"---\n{header}---\n\n{markdown}\n", encoding="utf-8") + alternate = page_url(site, path.with_suffix(".md")) + links = ( + f'\n' + f'\n' + f'\n' + f'\n' + ) + path.write_text(source.replace("", links + "", 1), encoding="utf-8") + count += 1 + print(f"Prepared Markdown for {count} reading pages.") diff --git a/src/adb/AdbCommand.test.ts b/src/adb/AdbCommand.test.ts index 5fff97243e..89cac7286a 100644 --- a/src/adb/AdbCommand.test.ts +++ b/src/adb/AdbCommand.test.ts @@ -65,7 +65,21 @@ it("recognizes only the completed empty false result as absence", async () => { killed: false, }), ); - await expect(probe()).resolves.toEqual({ ok: true, value: false }); + await expect(probe()).resolves.toEqual({ + ok: true, + value: { + matches: false, + capturedOutput: { + exit_code: 1, + stdout: "", + stderr: "", + stdout_bytes: 0, + stderr_bytes: 0, + signal: null, + truncated: false, + }, + }, + }); }); it.each([ @@ -124,7 +138,21 @@ it("recognizes an empty successful probe as existence", async () => { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), }); - await expect(probe()).resolves.toEqual({ ok: true, value: true }); + await expect(probe()).resolves.toEqual({ + ok: true, + value: { + matches: true, + capturedOutput: { + exit_code: 0, + stdout: "", + stderr: "", + stdout_bytes: 0, + stderr_bytes: 0, + signal: null, + truncated: false, + }, + }, + }); }); it.each(["stdout", "stderr"] as const)( @@ -265,3 +293,81 @@ it("keeps a physical failure even when the later abort reason is that same error error: { _tag: "AnalysisProtocolError", cause: failure }, }); }); + +const STARTUP_NOTICES = [ + "* daemon not running; starting now at tcp:5037\n* daemon started successfully\n", + "* daemon not running. starting it now on port 5037 *\r\n* daemon started successfully *\r\n", +]; +it.each(STARTUP_NOTICES)( + "recognizes completed true and false probes with successful startup: %j", + async (stderr) => { + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }); + await expect(probe()).resolves.toMatchObject({ + ok: true, + value: { + matches: true, + capturedOutput: { + stdout: "", + stderr, + exit_code: 0, + stderr_bytes: Buffer.byteLength(stderr), + }, + }, + }); + vi.mocked(execFileBytes).mockRejectedValueOnce( + Object.assign(new Error("false"), { + code: 1, + stdout: "", + stderr, + signal: null, + killed: false, + }), + ); + await expect(probe()).resolves.toMatchObject({ + ok: true, + value: { + matches: false, + capturedOutput: { + stdout: "", + stderr, + exit_code: 1, + stderr_bytes: Buffer.byteLength(stderr), + }, + }, + }); + }, +); + +it.each([ + "* daemon not running; starting now at tcp:5037\n", + "* daemon failed to start\n", + "* daemon started successfully\n* daemon not running; starting now at tcp:5037\n", + "* daemon started successfully\nunexpected\n", + "\n", + " ", +])("refuses an incomplete or unknown startup output: %j", async (stderr) => { + vi.mocked(execFileBytes).mockResolvedValueOnce({ + stdout: Buffer.alloc(0), + stderr: Buffer.from(stderr), + }); + await expect(probe()).resolves.toMatchObject({ + ok: false, + error: { capturedOutput: { stderr, exit_code: 0 } }, + }); + vi.mocked(execFileBytes).mockRejectedValueOnce( + Object.assign(new Error("false"), { + code: 1, + stdout: "", + stderr, + signal: null, + killed: false, + }), + ); + await expect(probe()).resolves.toMatchObject({ + ok: false, + error: { capturedOutput: { stderr, exit_code: 1 } }, + }); +}); diff --git a/src/adb/AdbCommand.ts b/src/adb/AdbCommand.ts index be544ec3e5..5c627493d6 100644 --- a/src/adb/AdbCommand.ts +++ b/src/adb/AdbCommand.ts @@ -16,7 +16,11 @@ import { type ExecFileOutputFailure, } from "../process/ExecFileOutput.js"; import { packageChangeFailureReason } from "./AdbShellOutput.js"; -import { isSuccessfulDaemonStartNotice } from "./AdbDeviceOutput.js"; +import { + isSuccessfulDaemonStartNotice, + isDaemonStartingNotice, + hasOnlySuccessfulAdbStartupNotices, +} from "./AdbDeviceOutput.js"; import type { AdbBinarySelection } from "./AdbConfiguration.js"; const STDERR_EXCERPT_CHARACTERS = 512; @@ -164,12 +168,7 @@ export const runAdbCapture = async ( const line = stderr .slice(diagnosticStart, newline === -1 ? stderr.length : newline) .trimEnd(); - if ( - !isSuccessfulDaemonStartNotice(line) && - !/^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test( - line, - ) - ) + if (!isSuccessfulDaemonStartNotice(line) && !isDaemonStartingNotice(line)) break; diagnosticStart = newline === -1 ? stderr.length : newline + 1; } @@ -272,7 +271,38 @@ export const packageChangeRefusal = ( ); }; -/** Only the clean false result of test -e establishes absence. */ +/** Parsed predicate and the complete output that supports it. */ +export interface AdbPathProbe { + readonly matches: boolean; + readonly capturedOutput: { + readonly stdout: string; + readonly stderr: string; + readonly stdout_bytes: number; + readonly stderr_bytes: number; + readonly exit_code: 0 | 1; + readonly signal: null; + readonly truncated: false; + }; +} +const completedAdbPathProbe = ( + matches: boolean, + stderr: string, +): AdbPathProbe => ({ + matches, + capturedOutput: { + stdout: "", + stderr, + stdout_bytes: 0, + stderr_bytes: Buffer.byteLength(stderr), + exit_code: matches ? 0 : 1, + signal: null, + truncated: false, + }, +}); +const recognizedProbeStderr = (stderr: string): boolean => + stderr === "" || hasOnlySuccessfulAdbStartupNotices(stderr); + +/** Only a complete false predicate, with no device diagnostics, establishes absence. */ export const probeAdbPathExistence = async ( operation: string, serial: string, @@ -280,11 +310,11 @@ export const probeAdbPathExistence = async ( selection: AdbBinarySelection, limits: AdbRunLimits, signal: AbortSignal | undefined, -): Promise> => { +): Promise> => { return probeAdbPath(operation, serial, path, selection, limits, signal, "-e"); }; -/** Only the clean true/false result of test -d establishes directory status. */ +/** Completed predicate status is independent of recognized host startup notices. */ export const probeAdbPathDirectory = async ( operation: string, serial: string, @@ -292,7 +322,7 @@ export const probeAdbPathDirectory = async ( selection: AdbBinarySelection, limits: AdbRunLimits, signal: AbortSignal | undefined, -): Promise> => +): Promise> => probeAdbPath(operation, serial, path, selection, limits, signal, "-d"); const probeAdbPath = async ( @@ -303,7 +333,7 @@ const probeAdbPath = async ( limits: AdbRunLimits, signal: AbortSignal | undefined, predicate: "-d" | "-e", -): Promise> => { +): Promise> => { const fact = predicate === "-d" ? "destination_directory" : "destination_existence"; const probed = await runAdbCapture( @@ -315,8 +345,9 @@ const probeAdbPath = async ( signal, ); if (probed.ok) { - if (probed.value.stdout.length === 0 && probed.value.stderr.length === 0) - return ok(true); + const stderr = probed.value.stderr.toString("utf8"); + if (probed.value.stdout.length === 0 && recognizedProbeStderr(stderr)) + return ok(completedAdbPathProbe(true, stderr)); return err( new AnalysisProtocolError( `The ADB path ${predicate === "-d" ? "directory" : "existence"} probe returned unexpected output; destination status is unknown`, @@ -345,9 +376,9 @@ const probeAdbPath = async ( !failure.outputTruncated && failure.collectionFailure !== true && failure.stdout === "" && - failure.stderr === "" + recognizedProbeStderr(failure.stderr) ) - return ok(false); + return ok(completedAdbPathProbe(false, failure.stderr)); return err( new AnalysisProtocolError(error.message, { cause: error.cause, diff --git a/src/adb/AdbDeviceOutput.ts b/src/adb/AdbDeviceOutput.ts index dc24522b75..4892e2860f 100644 --- a/src/adb/AdbDeviceOutput.ts +++ b/src/adb/AdbDeviceOutput.ts @@ -40,6 +40,28 @@ export const isSuccessfulDaemonStartNotice = (line: string): boolean => line === "* daemon started successfully" || line === "* daemon started successfully *"; +/** Host-side startup line, shared by failure and successful probe parsing. */ +export const isDaemonStartingNotice = (line: string): boolean => + /^\* daemon not running(?:; starting now at [^\r\n]*|\. starting it now on port \d+ \*)$/u.test( + line, + ); + +/** No diagnostics beyond a completed, recognized startup sequence. */ +export const hasOnlySuccessfulAdbStartupNotices = (output: string): boolean => { + let started = false; + for (let offset = 0; offset < output.length;) { + const newline = output.indexOf("\n", offset); + const line = normalizeAdbOutputLine( + output.slice(offset, newline === -1 ? output.length : newline), + ); + if (isSuccessfulDaemonStartNotice(line)) started = true; + else if (isDaemonStartingNotice(line)) started = false; + else return false; + offset = newline === -1 ? output.length : newline + 1; + } + return started; +}; + const metadataValue = (remainder: string, key: string): string | null => { const match = new RegExp(`(?:^|\\s)${key}:(\\S+)`, "u").exec(remainder); return match === null ? null : match[1]!; diff --git a/src/adb/AdbProvider.ts b/src/adb/AdbProvider.ts index 36724a187a..ac8f80c610 100644 --- a/src/adb/AdbProvider.ts +++ b/src/adb/AdbProvider.ts @@ -2407,7 +2407,7 @@ export class AdbProvider { signal, ); if (!directoryProbe.ok) return directoryProbe; - const devicePath = directoryProbe.value + const devicePath = directoryProbe.value.matches ? `${input.device_path}${input.device_path.endsWith("/") ? "" : "/"}${basename(localPath)}` : input.device_path; const existsProbe = await probeAdbPathExistence( @@ -2419,7 +2419,7 @@ export class AdbProvider { signal, ); if (!existsProbe.ok) return existsProbe; - const deviceExists = existsProbe.value; + const deviceExists = existsProbe.value.matches; if (deviceExists && !input.overwrite) return err( new AnalysisOutputError( @@ -2440,6 +2440,17 @@ export class AdbProvider { signal, ); if (!pushed.ok) return pushed; + const observedProbeStartup = + directoryProbe.value.capturedOutput.stderr !== "" || + existsProbe.value.capturedOutput.stderr !== ""; + const probeOutput = observedProbeStartup + ? { + device_path_probes: { + directory: { ...directoryProbe.value.capturedOutput }, + existence: { ...existsProbe.value.capturedOutput }, + }, + } + : undefined; const receipt = ( deviceDigest: AdbDeviceDigest, rawResult?: { @@ -2463,7 +2474,9 @@ export class AdbProvider { }, provider, { - ...(rawResult === undefined ? {} : { rawResult }), + ...(rawResult === undefined && probeOutput === undefined + ? {} + : { rawResult: { ...rawResult, ...probeOutput } }), limitations: [ "Push writes a private snapshot of the caller's file to the device", ...(deviceDigest.device_digest_source === "unavailable" diff --git a/src/adb/AdbPushProbeEvidence.test.ts b/src/adb/AdbPushProbeEvidence.test.ts new file mode 100644 index 0000000000..099a7edf30 --- /dev/null +++ b/src/adb/AdbPushProbeEvidence.test.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; +import { execFileBytes, execFileOutput } from "../process/ExecFileOutput.js"; +import { AdbProvider } from "./AdbProvider.js"; +vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({ + ...(await importOriginal()), + execFileBytes: vi.fn(), + execFileOutput: vi.fn(), +})); +const startup = + "* daemon not running; starting now at tcp:5037\n* daemon started successfully\n"; +it.each([false, true])( + "retains startup probe evidence alongside digest observations (recognized=%s)", + async (recognized) => { + vi.clearAllMocks(); + const root = await mkdtemp(join(tmpdir(), "rea-adb-probe-evidence-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const source = join(root, "input.bin"); + const bytes = Buffer.from("caller-owned bytes"); + await writeFile(source, bytes); + const digest = createHash("sha256").update(bytes).digest("hex"); + const provider = new AdbProvider({ + environment: { REA_ADB_PATH: process.execPath }, + }); + onTestFinished(() => provider.close()); + vi.mocked(execFileOutput).mockImplementation(async (_binary, args) => { + expect(args).toEqual(["version"]); + const stdout = + "Android Debug Bridge version 1.0.41\nVersion 37.0.0-fixture\n"; + return { + stdout, + stderr: "", + stdoutBytes: Buffer.byteLength(stdout), + stderrBytes: 0, + }; + }); + const digestOutput = recognized + ? `${digest} /sdcard/receiver/input.bin\n` + : "unrecognized sha256sum fixture output\n"; + const commands: string[][] = []; + let staged: string | undefined; + vi.mocked(execFileBytes).mockImplementation(async (_binary, args) => { + const command = [...args]; + commands.push(command); + expect(command.slice(0, 2)).toEqual(["-s", "device"]); + if ( + command[2] === "shell" && + command[3] === "test" && + command[4] === "-d" + ) + return { stdout: Buffer.alloc(0), stderr: Buffer.from(startup) }; + if ( + command[2] === "shell" && + command[3] === "test" && + command[4] === "-e" + ) + throw Object.assign(new Error("absent"), { + code: 1, + signal: null, + killed: false, + stdout: "", + stderr: startup, + }); + if (command[2] === "push") { + staged = command[3]; + expect(staged).not.toBe(source); + expect((await readFile(staged!)).equals(bytes)).toBe(true); + return { + stdout: Buffer.from("1 file pushed\n"), + stderr: Buffer.alloc(0), + }; + } + if (command[2] === "shell" && command[3] === "sha256sum") + return { stdout: Buffer.from(digestOutput), stderr: Buffer.alloc(0) }; + throw new Error("Unexpected adb fixture command"); + }); + const result = await provider.execute({ + operation: "push_adb_file", + input: { + serial: "device", + local_path: source, + device_path: "/sdcard/receiver", + overwrite: false, + }, + }); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + sha256: digest, + device_sha256: recognized ? digest : null, + overwritten: false, + device_digest_source: recognized ? "device_sha256sum" : "unavailable", + }); + expect(result.value.rawResult).toEqual({ + device_path_probes: { + directory: { + stdout: "", + stderr: startup, + stdout_bytes: 0, + stderr_bytes: Buffer.byteLength(startup), + exit_code: 0, + signal: null, + truncated: false, + }, + existence: { + stdout: "", + stderr: startup, + stdout_bytes: 0, + stderr_bytes: Buffer.byteLength(startup), + exit_code: 1, + signal: null, + truncated: false, + }, + }, + ...(recognized + ? {} + : { device_sha256sum: { stdout: digestOutput, stderr: "" } }), + }); + expect(commands.map((command) => command.slice(2, 5))).toEqual([ + ["shell", "test", "-d"], + ["shell", "test", "-e"], + ["push", staged, "/sdcard/receiver"], + ["shell", "sha256sum", "/sdcard/receiver/input.bin"], + ]); + expect((await readFile(source)).equals(bytes)).toBe(true); + expect(staged).toBeDefined(); + await expect(stat(staged!)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); diff --git a/src/android/AndroidManifestComponentDetails.ts b/src/android/AndroidManifestComponentDetails.ts index a3f1d7ebdc..3f5ecdbf5d 100644 --- a/src/android/AndroidManifestComponentDetails.ts +++ b/src/android/AndroidManifestComponentDetails.ts @@ -32,6 +32,20 @@ const projectedDataAttributes = new Set([ const literalBoolean = (value: string | null): boolean | null => value === "true" ? true : value === "false" ? false : null; +/** Whether a filter names an action, which Android requires to keep an activity filter. */ +const declaresAction = (filter: Element): boolean => { + for (let index = 0; index < filter.childNodes.length; index += 1) { + const member = filter.childNodes.item(index); + if ( + isManifestElement(member) && + member.nodeName === "action" && + androidAttribute(member, "name") !== null + ) + return true; + } + return false; +}; + /** One decoded-manifest lifetime owns nested declarations and cumulative copy budgets. */ export const createAndroidManifestDetailsReader = ( source: string, @@ -99,6 +113,7 @@ export const createAndroidManifestDetailsReader = ( let legacyValuesRemaining = limits.intentValues; let legacyTruncated = false; let filterCount = 0; + let actionFilter = false; for (let index = 0; index < component.childNodes.length; index += 1) { const child = component.childNodes.item(index); if (!isManifestElement(child)) continue; @@ -111,6 +126,8 @@ export const createAndroidManifestDetailsReader = ( continue; } if (child.nodeName !== "intent-filter") continue; + // Checked past the filter bound too; one action-bearing filter suffices. + actionFilter ||= declaresAction(child); const filterIndex = filterCount++; if (filterIndex >= limits.intentFilters) { partial = true; @@ -237,6 +254,7 @@ export const createAndroidManifestDetailsReader = ( actions, categories, filterCount, + actionFilter, legacyTruncated, partial, detailLimited, diff --git a/src/android/AndroidManifestComponents.ts b/src/android/AndroidManifestComponents.ts index 92fbc7be63..03f63f3d46 100644 --- a/src/android/AndroidManifestComponents.ts +++ b/src/android/AndroidManifestComponents.ts @@ -454,11 +454,18 @@ export const parseAndroidManifestComponents = ( : null; const exportedNonliteral = declaredExported !== null && exportedLiteral === null; + // Android discards an activity, alias or receiver filter that declares + // no action, while a service keeps it. Only kept filters select the + // export default and the API 31 explicit-export requirement. + const keptFilters = + component.nodeName === "service" + ? details.filterCount > 0 + : details.actionFilter; const explicitExportRequired = declaredExported === null && targetSdkLevel !== null && targetSdkLevel >= 31 && - details.filterCount > 0 && + keptFilters && (component.nodeName === "activity" || component.nodeName === "activity-alias" || component.nodeName === "service" || @@ -466,7 +473,7 @@ export const parseAndroidManifestComponents = ( const filteredExportUnknown = declaredExported === null && targetSdkLevel === null && - details.filterCount > 0 && + keptFilters && (component.nodeName === "activity" || component.nodeName === "activity-alias" || component.nodeName === "service" || @@ -491,7 +498,7 @@ export const parseAndroidManifestComponents = ( ? null : component.nodeName === "provider" ? providerDefault - : details.filterCount > 0, + : keptFilters, permission, permission_source: effectivePermissionSource, intent_filter_count: details.filterCount, diff --git a/src/android/AndroidMappingFile.ts b/src/android/AndroidMappingFile.ts index 46577f1bbc..a0f5b4cc72 100644 --- a/src/android/AndroidMappingFile.ts +++ b/src/android/AndroidMappingFile.ts @@ -4,6 +4,7 @@ import { bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { @@ -99,6 +100,7 @@ const readFailurePrimary = ( if (owned?.reason === "path") return invalidMapping(read, "invalid_value", owned.message, { cause: owned, + ...stableArtifactInputRecovery(owned), }); if (primary instanceof Error && "code" in primary) { if (primary.code === "EACCES" || primary.code === "EPERM") diff --git a/src/android/JadxDoctor.ts b/src/android/JadxDoctor.ts index 9667f49920..27074f6ea2 100644 --- a/src/android/JadxDoctor.ts +++ b/src/android/JadxDoctor.ts @@ -1,9 +1,11 @@ -import type { RetainedResourceScope } from "../application/AnalysisProvider.js"; +import type { + RetainedResourceScope, + ProviderAvailability, +} from "../application/AnalysisProvider.js"; import type { DoctorProviderCheck, DoctorProviderInspection, } from "../application/Doctor.js"; -import type { ProviderAvailability } from "../application/AnalysisProvider.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { inspectJadxAvailability, diff --git a/src/apkSignature/apkSignatureFailures.ts b/src/apkSignature/apkSignatureFailures.ts index 659506ecc9..d63a2942b2 100644 --- a/src/apkSignature/apkSignatureFailures.ts +++ b/src/apkSignature/apkSignatureFailures.ts @@ -1,5 +1,6 @@ import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -230,9 +231,11 @@ const artifactReaderFailure = ( }, ); case "path": - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); default: return adapterFailure(cause, path); } diff --git a/src/application/AnalysisContextQueries.test.ts b/src/application/AnalysisContextQueries.test.ts index 8322fadf43..392a9b1add 100644 --- a/src/application/AnalysisContextQueries.test.ts +++ b/src/application/AnalysisContextQueries.test.ts @@ -8,6 +8,7 @@ import { import { createAnalysisExecution, type AnalysisOperationPort, + type AnalysisExecution, } from "./AnalysisProvider.js"; import { AnalysisCapabilityUnavailableError, @@ -21,7 +22,6 @@ import { primaryAnalysisError, type AnalysisError, } from "../domain/analysisErrorBase.js"; -import type { AnalysisExecution } from "./AnalysisProvider.js"; import type { Result } from "../domain/result.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, ok } from "../domain/result.js"; diff --git a/src/application/AnalysisContextQueries.ts b/src/application/AnalysisContextQueries.ts index 03176264cf..85ba5279bb 100644 --- a/src/application/AnalysisContextQueries.ts +++ b/src/application/AnalysisContextQueries.ts @@ -6,6 +6,7 @@ import type { import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; @@ -150,8 +151,12 @@ export const inspectAddressContext = async ( const procedure: Facet = resolution?.ok ? { state: "available", value: resolution.value } : unavailableFacet(resolution?.error, operations[1]); - const comment = facet(results[2], operations[2]); - const inlineComment = facet(results[3], operations[3]); + const entry = + resolution?.ok && resolution.value.found + ? resolution.value.procedure.address + : undefined; + const comment = commentFacet(results[2], operations[2], entry); + const inlineComment = commentFacet(results[3], operations[3], entry); const bookmarks = facet(results[4], operations[4]); return ok({ address: input.address, @@ -170,6 +175,28 @@ export const inspectAddressContext = async ( }); }; +const commentFacet = ( + result: Result | undefined, + operation: "comment" | "inline_comment", + entry: string | undefined, +): Facet => { + if ( + result?.ok === false && + result.error instanceof AnalysisCapabilityUnavailableError && + !result.error.retryable && + result.error.remediationAction === undefined && + result.error.continuingCallerGuidance?.remediationAction === undefined && + entry !== undefined + ) { + return { + state: "unavailable", + reason: result.error.message, + remediation: `If analyze_function is available for this same document, call it with ${JSON.stringify({ procedure: entry })}; use its summary's parent_evidence_id with inspect_analysis_view's native comments facet. Check facet availability, pagination, and the exact requested address; an omitted comment does not establish absence.`, + }; + } + return facet(result, operation); +}; + const facet = ( result: Result | undefined, operation: AnalysisOperation, @@ -182,6 +209,19 @@ const unavailableFacet = ( error: AnalysisError | undefined, operation: AnalysisOperation, ): Facet => { + if (error instanceof AnalysisCapabilityUnavailableError) { + const projected = projectAnalysisError(error); + return { + state: "unavailable", + reason: error.userMessage ?? error.message, + remediation: + error.retryable || + error.remediationAction !== undefined || + error.continuingCallerGuidance?.remediationAction !== undefined + ? projected.remediation.action + : "Review provider availability in binary_session with detail complete and select another supported provider if appropriate. After switching, if a name is known and search_procedures is available, use it to find candidates and verify their identity and coordinates.", + }; + } if (error instanceof AnalysisInputError) { const projected = projectAnalysisError(error); if (projected.code === "invalid_request") diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 74d41d2d81..4277072e16 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -18,6 +18,10 @@ import type { EvidenceSubjectTarget, } from "../domain/evidence.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; +import { + analysisSourceDescriptorSchema, + type AnalysisSourceDescriptor, +} from "../domain/analysisSource.js"; import type { ProgressReporter } from "./ProgressReporter.js"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import type { ProcessLineageObservation } from "../process/ProcessOwnership.js"; @@ -30,8 +34,12 @@ import { export interface ExecutionOptions { /** Ephemeral state shared only by one composed workflow. */ readonly workScope?: AnalysisWorkScope; + /** A single CLI answer bound to its isolated target, not a persistent default document. */ + readonly snapshotContext?: "one-shot-target"; readonly signal?: AbortSignal; readonly progress?: ProgressReporter; + /** The composed workflow will cite this observation and needs its Evidence. */ + readonly retainEvidence?: true; /** * Set when a procedure selector came from provider output, such as a * revisited callee, rather than from the caller. A provider-derived @@ -56,6 +64,12 @@ export type AnalysisOperation = /** Atomic successful provider observation with exact producing provenance. */ export interface AnalysisExecution extends AnalysisObservation { readonly provider: ProviderIdentity; + /** + * Compact provider-owned facts for this execution. This is not an Evidence + * record and is not restored by snapshot replay; consumers must treat its + * absence as unknown. + */ + readonly sourceDescriptor?: AnalysisSourceDescriptor; /** Session-issued identity of the committed record consumed by this execution. */ readonly evidenceId?: string; /** Result-specific veto of snapshot replay; Evidence remains retainable. */ @@ -71,6 +85,7 @@ export interface AnalysisExecution extends AnalysisObservation { interface CreateAnalysisExecutionOptions { readonly rawResult?: unknown; + readonly sourceDescriptor?: AnalysisSourceDescriptor; readonly analysisProfile?: AnalysisProfileCommitment; readonly cachePolicy?: "live"; readonly limitations?: readonly string[]; @@ -110,6 +125,13 @@ export function createAnalysisExecution( ? null : jsonValueSchema.parse(options.rawResult), provider, + ...(options.sourceDescriptor === undefined + ? {} + : { + sourceDescriptor: analysisSourceDescriptorSchema.parse( + options.sourceDescriptor, + ), + }), ...(options.cachePolicy === undefined ? {} : { cachePolicy: options.cachePolicy }), @@ -258,8 +280,10 @@ type CapabilityAvailability = readonly available: false; readonly reason: string; readonly availabilityCode?: ProviderRejectionCode; + /** Provider-owned explanation and recovery for an attempted operation. */ + readonly userMessage?: string; /** - * Repair instruction from the readiness owner that made this operation + * Recovery instruction from the owner that made this operation * unavailable. Static capability facts omit it. */ readonly remediation?: string; diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 3ce2e473f3..68845303df 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -85,6 +85,10 @@ import { hasDiskImageSignature } from "../domain/apple/diskImageFormat.js"; import { hasGodotPckHeader } from "../domain/godotPckFormat.js"; import { hasUImageMagic } from "../domain/uimageFormat.js"; import { hasUnityFsHeader } from "../domain/unityfsFormat.js"; +import { + hasWxapkgEncryptedMagic, + hasWxapkgHeader, +} from "../domain/wxapkgFormat.js"; import { detectHexRecordEncoding } from "../domain/firmware/hexRecords.js"; import { hasUf2Block } from "../domain/firmware/uf2.js"; import { @@ -189,6 +193,9 @@ export const parseBinaryTarget = async ( const { executable: path, infoPlist } = resolved.value; const bundle = infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist }; + // A bundle directory is selected for its executable, so the executable's + // own name classifies it; a selected file keeps the caller's spelling. + const classifiedAs = infoPlist === undefined ? candidate : path; return withOwnedRegularFile( path, { @@ -199,7 +206,13 @@ export const parseBinaryTarget = async ( try { return await readOpenedBinaryTarget( source, - { path, sourcePath, selectedPath: candidate, ...bundle }, + { + path, + sourcePath, + selectedPath: candidate, + classifiedAs, + ...bundle, + }, options, ); } catch (cause: unknown) { @@ -279,13 +292,23 @@ const readOpenedBinaryTarget = async ( readonly path: string; readonly sourcePath: string; readonly selectedPath: string; + /** Spelling whose name supplies format hints: the selected file, or a bundle's executable. */ + readonly classifiedAs: string; readonly bundleInfoPlist?: string; }, options: BinaryTargetResolverOptions, ): Promise => { const { handle, initial: metadata } = source; - const { path, sourcePath, selectedPath, bundleInfoPlist } = selected; + const { path, sourcePath, selectedPath, classifiedAs, bundleInfoPlist } = + selected; const bundle = bundleInfoPlist === undefined ? {} : { bundleInfoPlist }; + // Every admitted target shares one identity; routes add their own facts. + const identity = async () => ({ + path, + sourcePath, + ...bundle, + sha256: await sha256Handle(handle, path, metadata, signal), + }); const hostArchitecture = options.hostArchitecture ?? process.arch; const { targetKind, formatHint, signal, architecture, entryPoint } = options; throwIfTargetResolutionCancelled(signal); @@ -322,10 +345,7 @@ const readOpenedBinaryTarget = async ( }), ); return ok({ - path, - sourcePath, - ...bundle, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await identity()), kind: "executable", format: "raw-binary", architecture: admitted.value.architecture, @@ -373,10 +393,7 @@ const readOpenedBinaryTarget = async ( }), ); return ok({ - path, - sourcePath, - ...bundle, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await identity()), kind: "executable", format: "gamecube-dol", architecture: admitted.value.architecture, @@ -414,10 +431,8 @@ const readOpenedBinaryTarget = async ( ), ); return ok({ - path, - sourcePath, + ...(await identity()), artifactSelectedPath: selectedPath, - sha256: await sha256Handle(handle, path, metadata, signal), kind: "archive", format: "esp-flash-image", espFlash: { @@ -444,10 +459,7 @@ const readOpenedBinaryTarget = async ( }), ); return ok({ - path, - sourcePath, - ...bundle, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await identity()), kind: "executable", format: "dos-com", architecture: "x86", @@ -456,11 +468,15 @@ const readOpenedBinaryTarget = async ( } // Probe WASM content before suffix-only database routing; an explicit // database selection keeps the caller's chosen interpretation. - const classificationPath = artifactNameForClassification(selectedPath, path); + const classificationPath = artifactNameForClassification(classifiedAs, path); const artifactFormat = targetKind === "database" ? undefined : await detectArtifactFormat(classificationPath, source, signal, path); + const artifactIdentity = async () => ({ + ...(await identity()), + artifactSelectedPath: selectedPath, + }); if ( targetKind === "database" || (targetKind === undefined && @@ -469,19 +485,12 @@ const readOpenedBinaryTarget = async ( path.toLowerCase().endsWith(".hop")) ) return ok({ - path, - sourcePath, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await identity()), kind: "database", format: "analysis-database", }); if (artifactFormat !== undefined) { - const identity = { - path, - sourcePath, - artifactSelectedPath: selectedPath, - sha256: await sha256Handle(handle, path, metadata, signal), - }; + const identity = await artifactIdentity(); if (artifactFormat === "esp-flash-image") return ok({ ...identity, @@ -512,12 +521,9 @@ const readOpenedBinaryTarget = async ( )) !== undefined ) return ok({ - path, - sourcePath, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await artifactIdentity()), kind: "archive", format: zipRootPackageFormat(classificationPath), - artifactSelectedPath: selectedPath, }); const reason = typeof detected.error === "string" @@ -535,10 +541,7 @@ const readOpenedBinaryTarget = async ( ); } return ok({ - path, - sourcePath, - ...bundle, - sha256: await sha256Handle(handle, path, metadata, signal), + ...(await identity()), kind: "executable", ...detected.value, }); @@ -778,6 +781,7 @@ const detectArtifactFormat = async ( if (hasZipSignature(magic)) return zipRootPackageFormat(lower); if (hasGodotPckHeader(magic)) return "godot-pck"; if (hasUnityFsHeader(magic)) return "unityfs"; + if (hasWxapkgEncryptedMagic(magic) || hasWxapkgHeader(magic)) return "wxapkg"; if (hasUImageMagic(magic)) return "uimage"; const hexRecords = detectHexRecordEncoding(magic); if (hexRecords !== undefined) return hexRecords; @@ -884,6 +888,7 @@ const isArchiveFormat = ( "ines", "godot-pck", "unityfs", + "wxapkg", "uimage", "dmg", "pkg", diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 089685cd33..074dd92d23 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -196,6 +196,54 @@ it("reports the active resource session's dispatch policy without limiting indep ); }); +it("derives closed-session address and explicit-path APK availability from their contracts", () => { + expect(entry("inspect_address_context", status())).toMatchObject({ + available: false, + reason: "target_required", + remediation: expect.any(String), + }); + expect(entry("inspect_apk_signature", status())).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + expect( + entry( + "inspect_address_context", + status({ + open: true, + kind: "executable", + capabilities: [ + { + operation: "inspect_address_context", + available: true, + reason: null, + }, + ], + }), + ), + ).toMatchObject({ available: true, reason: "available" }); + expect(entry("inspect_apk_signature", status({ open: true }))).toMatchObject({ + available: true, + reason: "available", + }); + for (const kind of ["artifact", "archive"] as const) { + expect( + entry("inspect_address_context", status({ open: true, kind })), + ).toMatchObject({ + available: false, + reason: "target_unsupported", + remediation: expect.stringContaining("explicit document"), + }); + expect( + entry("inspect_apk_signature", status({ open: true, kind })), + ).toMatchObject({ + available: true, + reason: "available", + }); + } +}); + describe("ADB and Apktool host availability", () => { it("uses only their selected owner probes", () => { const ready = { @@ -513,6 +561,31 @@ describe("compact tool availability", () => { }); }); +it("reports target restrictions for navigation modes even when providers are declared", () => { + const capabilities = [ + "current_document", + "current_address", + "resolve_containing_procedure", + ].map((operation) => ({ operation, available: true, reason: null })); + for (const kind of ["artifact", "archive"] as const) { + const navigation = entry( + "get_navigation_context", + status({ open: true, kind, capabilities }), + ); + expect(navigation).toMatchObject({ + available: false, + reason: "target_unsupported", + default_mode_available: false, + modes: [ + { name: "current_selection", available: false }, + { name: "explicit_document", available: false }, + ], + }); + for (const mode of navigation.modes ?? []) + expect(mode.remediation).toBe(navigation.remediation); + } +}); + describe("partial navigation availability", () => { it("requires a target before diagnosing navigation provider modes", () => { expect(entry("get_navigation_context", status())).toMatchObject({ @@ -880,6 +953,62 @@ describe("trace_feature requirements", () => { }); }); +describe("firmware operation recovery", () => { + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + "keeps inspection=%s and extraction=%s independent", + (inspection, extraction) => { + const inventory = buildCapabilityInventory(status(), { + processCaptureEnabled: false, + firmwareInspectionEnabled: inspection, + firmwareExtractionEnabled: extraction, + }); + for (const [name, enabled, command, otherCommand] of [ + [ + "inspect_firmware_regions", + inspection, + "REA_BINWALK_COMMAND", + "REA_UNBLOB_COMMAND", + ], + [ + "extract_firmware", + extraction, + "REA_UNBLOB_COMMAND", + "REA_BINWALK_COMMAND", + ], + ] as const) { + const tool = inventory.find((candidate) => candidate.name === name); + expect(tool).toMatchObject({ + available: enabled, + reason: enabled ? "available" : "provider_missing", + }); + if (enabled) { + expect(tool?.remediation).toBeNull(); + } else { + expect(tool?.remediation).toContain(command); + expect(tool?.remediation).not.toContain(otherCommand); + expect(tool?.remediation).toContain("prlimit"); + expect(tool?.remediation).toContain("procps ps"); + expect(tool?.remediation?.includes("external extractor")).toBe( + name === "extract_firmware", + ); + } + } + const byName = (left: ToolAvailability, right: ToolAvailability) => + left.name.localeCompare(right.name); + expect( + expandToolAvailability(compactToolAvailability(inventory)).toSorted( + byName, + ), + ).toEqual(inventory.toSorted(byName)); + }, + ); +}); + const HOST_RESTRICTED_TOOLS = [ ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index b296483882..f5b9ac5fc5 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -1,6 +1,9 @@ import { PCAP_CAPTURE_UNSUPPORTED_HOST_REASON } from "../domain/pcap/pcapAnalysis.js"; import { DEFAULT_CALL_RELATIONSHIP_DIRECTION } from "../contracts/enhancedInputs.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "../contracts/firmware/firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "../contracts/firmware/firmwareToolRequirements.js"; import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js"; import { NATIVE_DISPATCH_METADATA_SOURCES } from "./native/NativeDispatchMetadataInspection.js"; import { NATIVE_UI_ACTION_OPERATIONS } from "./native/NativeUiActionTrace.js"; @@ -413,12 +416,25 @@ const availabilityFor = (context: AvailabilityContext): Availability => { ) return { reason: "available", remediation: null }; if (context.name === "get_navigation_context") { + const target = targetAvailability(context); + if (target !== null) { + return { + ...target, + defaultModeAvailable: false, + modes: NAVIGATION_CONTEXT_MODES.map((mode) => ({ + name: mode.name, + available: false, + missing_operations: [...mode.requirements], + remediation: target.remediation, + })), + }; + } const modes = availabilityForModes( NAVIGATION_CONTEXT_MODES, "current_selection", context.descriptors, ); - return { ...modes, ...targetAvailability(context) }; + return modes; } const javascriptApplication = javascriptApplicationAvailability(context); if (javascriptApplication !== null) return javascriptApplication; @@ -464,11 +480,10 @@ const javascriptApplicationAvailability = ({ : null; }; -const workflowAvailabilityFor = ({ - name, - kind, - policy, -}: AvailabilityContext): Availability | null => { +const workflowAvailabilityFor = ( + context: AvailabilityContext, +): Availability | null => { + const { name, kind, policy } = context; if (name === "start_gdb_session" || name === "start_rizin_debug_session") return hostRestrictedAvailability( policy, @@ -596,15 +611,19 @@ const workflowAvailabilityFor = ({ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", ); if (kind === "firmware-provider") { - const { binwalk, unblob } = FIRMWARE_TOOL_REQUIREMENTS; const inspection = name === "inspect_firmware_regions"; + const operation = inspection + ? "inspect_firmware_regions" + : "extract_firmware"; + const requirement = + FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES[operation]]; return hostRestrictedAvailability( policy, inspection ? "firmwareInspection" : "firmwareExtraction", inspection ? policy.firmwareInspectionEnabled : policy.firmwareExtractionEnabled, - `On Linux, provide an absolute ${binwalk.commandEnvironment} (${binwalk.name} ${binwalk.line}, verified with ${binwalk.version}) or ${unblob.commandEnvironment} (${unblob.name} ${unblob.line}, verified with ${unblob.version}) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.`, + `On Linux, provide an absolute ${requirement.commandEnvironment} (${requirement.requirement}) path, util-linux prlimit and procps ps.${inspection ? "" : " Extraction also requires the selected format’s external extractor."}`, ); } if (kind === "android-provider") @@ -675,7 +694,10 @@ const workflowAvailabilityFor = ({ remediation: "Start REA with its JavaScript runtime provider available.", }; - if (kind === "session") return { reason: "available", remediation: null }; + if (kind === "session") + return ( + targetAvailability(context) ?? { reason: "available", remediation: null } + ); return null; }; @@ -816,12 +838,17 @@ const targetAvailability = ({ targetKind !== undefined && targetKind !== "executable" && targetKind !== "database" && - (kind === "official-proxy" || kind === "enhanced") + (kind === "official-proxy" || + kind === "enhanced" || + name === "get_navigation_context" || + name === "inspect_address_context") ) return { reason: "target_unsupported", remediation: - "Inventory or extract a native executable, then call open_binary on that executable.", + name === "inspect_address_context" + ? "Inventory or extract a native executable, then call open_binary on that executable. An explicit document can still be queried, but its facets may be unavailable." + : "Inventory or extract a native executable, then call open_binary on that executable.", }; if ( targetOpen && diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index ce2e56daeb..fccc63be21 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -152,9 +152,10 @@ const parseDocument = ( // Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so // comments, trailing commas, a BOM, or an empty existing file must be // rejected instead of registering into a document the client cannot load. - // Kimi also reads mcp.json with JSON.parse and an empty-file fallback. - if (format === "pi" || format === "kimi") { - if (format === "kimi" && text.trim().length === 0) return {}; + // Kimi and LM Studio also read mcp.json with JSON.parse; an empty file holds + // nothing to preserve. + if (format === "pi" || format === "kimi" || format === "lm_studio") { + if (format !== "pi" && text.trim().length === 0) return {}; return requireObject(JSON.parse(text), "The configuration root"); } // An empty file, which some clients create before any server is added, @@ -1748,7 +1749,8 @@ export const clientRegistrationEntry = ( : { environment: { ...environment } }), }; case "kimi": - // Kimi infers stdio from command; it uses `transport`, not `type`. + case "lm_studio": + // Both infer stdio from command; Kimi uses `transport`, not `type`. return { command: executable, args, diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 8a1b84afa7..1173106394 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -127,6 +127,62 @@ export const clientRegistrationEnvironmentDetails = ( return validatedDetails(environment, validation, platform); }; +/** Setting names a registration does not carry as the value selected here. */ +export interface RecordedProviderSettings { + /** Selected here and absent from the registration's recorded environment. */ + readonly unrecorded: readonly string[]; + /** Recorded with text other than the value selected here. */ + readonly differing: readonly string[]; + /** Recorded as an expression, or in a map REA could not read. */ + readonly unresolved: readonly string[]; +} + +/** + * Compare settings selected in this process with a registration's own file. + * The server's environment also depends on what its client passes on, which is + * not observable here, so a gap is never a claim about the effective value. + * Returns only names: recorded values stay in the file that owns them. + */ +export const recordedProviderSettings = ( + parsed: ClientConfigurationDocument, + selected: Readonly>, + platform: NodeJS.Platform = process.platform, +): RecordedProviderSettings | undefined => { + const names = Object.keys(selected).sort(compareUnicodeCodePoints); + if (names.length === 0) return undefined; + const details = clientRegistrationEnvironmentDetails( + parsed, + {}, + "fragment", + platform, + ); + if (!details.ok) return { unrecorded: [], differing: [], unresolved: names }; + const recorded = snapshotEnvironment(details.value.values, platform); + // Expression names fold like every other environment name on this platform. + const expressions = snapshotEnvironment( + Object.fromEntries( + [...details.value.unresolvedSettings].map((name) => [name, ""]), + ), + platform, + ); + const facts = { + unrecorded: names.filter( + (name) => recorded[name] === undefined && expressions[name] === undefined, + ), + differing: names.filter( + (name) => + recorded[name] !== undefined && recorded[name] !== selected[name], + ), + unresolved: names.filter((name) => expressions[name] !== undefined), + }; + return facts.unrecorded.length + + facts.differing.length + + facts.unresolved.length === + 0 + ? undefined + : facts; +}; + /** Resolve the async provider-specific budget through its retained reader owner. */ export const clientRegistrationEnvironmentDetailsWithBudget = async ( parsed: ClientConfigurationDocument | undefined, diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 24266348a9..6c1ab64228 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -28,14 +28,21 @@ import { import { clientEvidencePaths, clientOwnershipEvidencePaths, + compactInputSchemaByDefault, manualRegistrationRemediation, supportedClients, } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { dshDocument } from "./DshCordisPatch.js"; -import { clientRegistrationEnvironmentDetailsWithBudget } from "./ClientRegistrationEnvironment.js"; -import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { + clientRegistrationEnvironmentDetailsWithBudget, + recordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; +import type { + ProviderRequestBudgetResolver, + RecordedProviderSettings, +} from "./ClientRegistrationEnvironment.js"; import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { @@ -52,6 +59,8 @@ interface ClientRegistrationStatusBase { readonly package_reference?: string; /** Non-blocking timeout guidance; caller-selected values remain aligned. */ readonly advisory?: string; + /** Provider settings this process selected that the registration's file does not carry. */ + readonly provider_settings?: RecordedProviderSettings; } type RegistrationCommand = readonly [string, ...string[]]; @@ -99,6 +108,8 @@ interface ClientRegistrationReadOptions { */ readonly evidence?: "readiness" | "ownership"; readonly resolveProviderRequestBudget?: ProviderRequestBudgetResolver; + /** Settings to compare with each registration's file; omitted skips that read. */ + readonly providerSettings?: Readonly>; } /** Registration facts shared by diagnostics and update maintenance. */ @@ -269,12 +280,14 @@ export const readClientRegistrationInspections = async ( } const registration = parsedRegistration.value; const registrationEnvironment = - client.name === "kimi" || client.name === "codex" + compactInputSchemaByDefault(client) || client.name === "codex" ? await clientRegistrationEnvironmentDetailsWithBudget( parsed, {}, "effective", - options.resolveProviderRequestBudget, + client.name === "kimi" || client.name === "codex" + ? options.resolveProviderRequestBudget + : undefined, options.platform, ) : undefined; @@ -311,7 +324,7 @@ export const readClientRegistrationInspections = async ( command, commandFacts, platform: options.platform ?? process.platform, - kimiInputSchemaProfileSelected: + inputSchemaProfileSelected: registrationEnvironment?.ok === true && snapshotEnvironment( registrationEnvironment.value.values, @@ -362,6 +375,13 @@ export const readClientRegistrationInspections = async ( registrationEnvironment.value.timeoutInputs, ) : undefined, + options.providerSettings === undefined + ? undefined + : recordedProviderSettings( + parsed, + options.providerSettings, + options.platform, + ), ), activation, commandFacts.identity, @@ -432,14 +452,14 @@ const registrationAligned = ({ command, commandFacts, platform, - kimiInputSchemaProfileSelected, + inputSchemaProfileSelected, }: { readonly registration: ClientRegistration; readonly client: SetupClient; readonly command: readonly string[]; readonly commandFacts: ClientRegistrationCommandFacts; readonly platform: NodeJS.Platform; - readonly kimiInputSchemaProfileSelected: boolean; + readonly inputSchemaProfileSelected: boolean; }): "aligned" | "stale" | "unknown" => { const { identity } = commandFacts; if (identity === "unknown") return "unknown"; @@ -461,7 +481,8 @@ const registrationAligned = ({ registration.toolTimeoutMs === undefined) ) return "stale"; - if (client.name === "kimi" && !kimiInputSchemaProfileSelected) return "stale"; + if (compactInputSchemaByDefault(client) && !inputSchemaProfileSelected) + return "stale"; // A Harness registration only connects as the rea stdio server; another // server name or transport leaves the session without REA tools. if ( @@ -494,6 +515,7 @@ const configuredStatus = ( policyRemediation?: string, packageReference?: string, advisory?: string, + providerSettings?: RecordedProviderSettings, ): ClientRegistrationStatus => state === "aligned" ? { @@ -506,6 +528,9 @@ const configuredStatus = ( state, remediation: null, ...(advisory === undefined ? {} : { advisory }), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), } : { client: client.name, @@ -516,6 +541,9 @@ const configuredStatus = ( : { package_reference: packageReference }), state, remediation: policyRemediation ?? remediation(client), + ...(providerSettings === undefined + ? {} + : { provider_settings: providerSettings }), }; const timeoutAdvisory = ( diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 70935557c5..eb486c3f17 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -17,8 +17,15 @@ import type { ResolvedSessionOpen } from "./binary/BinarySessionOpen.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; import { withEvidenceCallerInputPath } from "../domain/evidence.js"; -import { createImmutableEvidenceBundle } from "../domain/evidenceBundle.js"; -import { retainAnalysisResultSources } from "./investigation/recordEvidenceSources.js"; +import { + createImmutableEvidenceBundle, + type EvidenceBundle, +} from "../domain/evidenceBundle.js"; +import { + retainAnalysisResultSources, + type AnalysisResultWithSources, +} from "./investigation/recordEvidenceSources.js"; +import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; @@ -139,13 +146,22 @@ export const runDirectAnalysis = async ( readonly architecture?: BinaryArchitecture; readonly entryPoint?: string; readonly espFlash?: EspFlashSelection; + readonly includeEvidenceSources?: boolean; + /** Capture the exact session Evidence available to a one-shot CLI adapter. */ + readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void; } = {}, -): Promise> => +): Promise> => withProcessCancellation(options.signal, (signal) => runAnalysis(dependencies, path, tool, arguments_, { logger: options.logger ?? silentLogger, snapshotPath: options.snapshotPath, signal, + ...(options.includeEvidenceSources === true + ? { includeEvidenceSources: true } + : {}), + ...(options.onEvidenceBundle === undefined + ? {} + : { onEvidenceBundle: options.onEvidenceBundle }), ...targetSelection(options), ...(options.providerId === undefined ? {} @@ -364,14 +380,16 @@ const runAnalysis = async ( readonly snapshotPath: string | undefined; readonly signal: AbortSignal; readonly evidenceParameters?: Readonly>; + readonly onEvidenceBundle?: (bundle: EvidenceBundle) => void; readonly createSession?: () => BinarySession | Promise; readonly providerId?: AnalysisProviderSelector; readonly formatHint?: ExecutableFormatHint; readonly architecture?: BinaryArchitecture; readonly entryPoint?: string; readonly espFlash?: EspFlashSelection; + readonly includeEvidenceSources?: boolean; }, -): Promise> => { +): Promise> => { const { logger, signal, snapshotPath } = options; const evidenceParameters = options.evidenceParameters ?? arguments_; let session: BinarySession; @@ -387,6 +405,29 @@ const runAnalysis = async ( )(config.value, logger); } let publishedSnapshot: PublishedEvidenceFile | undefined; + let sourceEvidence: readonly Evidence[] | undefined; + const captureSources = ( + evidence: Evidence, + supplied: readonly Evidence[] = [], + ): Result => { + if (options.includeEvidenceSources !== true) return ok(null); + const known = new Map( + supplied.map((record) => [record.evidence_id, record]), + ); + const closure = resolveInvestigationEvidenceClosure( + { + evidenceForAnalysis: (id) => { + const record = known.get(id) ?? session.evidenceForAnalysis(id); + if (record !== undefined) known.set(id, record); + return record; + }, + }, + evidence.evidence_links, + ); + if (!closure.ok) return closure; + sourceEvidence = closure.value.map((id) => known.get(id) as Evidence); + return ok(null); + }; const execute = async (): Promise> => { let isDirectory = false; if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) { @@ -458,8 +499,17 @@ const runAnalysis = async ( : routeProviderIdentity(route, tool), evidenceProfile, }); - if (cached !== undefined) - return ok(withEvidenceCallerInputPath(cached, path)); + if (cached !== undefined) { + const evidence = withEvidenceCallerInputPath(cached, path); + const sources = captureSources( + evidence, + snapshot.evidence_bundle.records, + ); + if (!sources.ok) + return err(sources.error.retainPartialObservation(evidence)); + options.onEvidenceBundle?.(snapshot.evidence_bundle); + return ok(evidence); + } } } const openOptions = { @@ -484,6 +534,7 @@ const runAnalysis = async ( evidenceParameters, signal, evidenceProfile, + saveSnapshot: snapshotPath !== undefined, }); if (!executed.ok) return executed; const { cachePolicy } = executed.value; @@ -496,12 +547,16 @@ const runAnalysis = async ( ); if (!recorded.ok) return err(recorded.error.retainPartialObservation(evidence)); + const sources = captureSources(evidence); + if (!sources.ok) + return err(sources.error.retainPartialObservation(evidence)); if (isWorkflowEvidenceTool(tool)) { const unknowns = recordWorkflowUnknowns({ name: tool, result: evidence.normalized_result, evidenceId: evidence.evidence_id, recordUnknown: (unknown) => session.recordUnknown(unknown), + evidenceRetained: true, }); if (!unknowns.ok) return err(unknowns.error.retainPartialObservation(evidence)); @@ -551,6 +606,7 @@ const runAnalysis = async ( return err(written.error.retainPartialObservation(evidence)); } } + options.onEvidenceBundle?.(session.exportEvidenceBundle()); return ok(evidence); }; const outcome = await withSessionCleanup<{ @@ -579,7 +635,12 @@ const runAnalysis = async ( directAnalysisCleanupFailure(result, outcome.cleanupError, path), ); } - return result.ok ? ok(result.value) : err(cliError(result.error, path)); + return result.ok + ? { + ...ok(result.value), + ...(sourceEvidence === undefined ? {} : { sourceEvidence }), + } + : err(cliError(result.error, path)); }; const executeAnalysisTool = async (input: { @@ -594,6 +655,7 @@ const executeAnalysisTool = async (input: { readonly evidenceParameters: Readonly>; readonly signal: AbortSignal; readonly evidenceProfile: AnalysisProfileCommitment | undefined; + readonly saveSnapshot: boolean; }): Promise< Result< { @@ -658,7 +720,12 @@ const executeAnalysisTool = async (input: { : { cachePolicy: result.value.cachePolicy }), }); } - let result = await session.execute(tool, input.arguments, { signal }); + let result = await session.execute(tool, input.arguments, { + signal, + ...(input.saveSnapshot && tool === "procedure_pseudo_code" + ? { snapshotContext: "one-shot-target" as const } + : {}), + }); if ( tool === "xrefs" && typeof input.arguments.address === "string" && diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 4d7092c5e9..3b096e53ca 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -57,6 +57,7 @@ import { type UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; import type { ProviderRequestBudgetResolver } from "./ClientRegistrationEnvironment.js"; +import { registrationSpelledSettings } from "./SetupRegistrationEnvironment.js"; import { collectDoctorDiagnostics, doctorHealthy, @@ -146,6 +147,7 @@ export interface DoctorHost { close?(): Promise; clientRegistrations?( selectedClientIds?: readonly string[], + providerSettings?: Readonly>, ): Promise; ilspyCmdVersion?(path: string): Promise; } @@ -276,18 +278,26 @@ const inspectDoctor = async ( hopperPath, providerInspections, } = await collectDoctorDiagnostics(target, host); - const identity = await collectDoctorIdentity(host, scope); + const hopperAvailable = + hopperPath !== undefined && + diagnosticChecks + .filter(({ name }) => name.startsWith("hopper")) + .every(({ ok }) => ok); + const selections = providerSettingSelections( + providerInspections, + hopperAvailable && hopperPath === host.configuredHopperPath + ? hopperPath + : undefined, + ); + const identity = await collectDoctorIdentity(host, scope, selections); const checks = [ ...diagnosticChecks, ...identity.checks, ...registrationAdvisoryChecks(identity.value.registrations), + ...registrationEnvironmentChecks(identity.value.registrations, selections), ]; const providers = { - hopperAvailable: - hopperPath !== undefined && - checks - .filter(({ name }) => name.startsWith("hopper")) - .every(({ ok }) => ok), + hopperAvailable, hopperConfigured: host.configuredHopperPath !== undefined, providerInspections, }; @@ -329,6 +339,77 @@ const registrationAdvisoryChecks = ( ], ); +/** Settings an available provider's checks in this report relied on. */ +interface ProviderSettingSelection { + readonly provider: string; + readonly settings: Readonly>; +} + +const providerSettingSelections = ( + inspections: readonly DoctorProviderInspection[] | undefined, + configuredHopperPath: string | undefined, +): readonly ProviderSettingSelection[] => [ + ...(inspections ?? []) + .filter(({ available }) => available) + .map(({ id, registrationEnvironment }) => ({ + provider: id, + settings: registrationEnvironment, + })), + // Startup probes the fixed and renamed locations on its own, so only an + // explicit path is something this terminal selected for the server. + ...(configuredHopperPath === undefined + ? [] + : [ + { + provider: "hopper", + settings: { HOPPER_LAUNCHER_PATH: configuredHopperPath }, + }, + ]), +]; + +/** + * A healthy provider check describes this terminal, not the server a client + * starts. Report what the registration's file leaves out without gating health: + * the client may still pass the setting on, which doctor cannot observe. + */ +const registrationEnvironmentChecks = ( + registrations: readonly ClientRegistrationStatus[], + selections: readonly ProviderSettingSelection[], +): readonly DoctorCheck[] => + registrations.flatMap(({ client, provider_settings: settings }) => { + if (settings === undefined) return []; + const { unrecorded, differing, unresolved } = settings; + const names = [...unrecorded, ...differing, ...unresolved]; + return [ + { + name: `registration_environment:${client}`, + ok: true, + classification: "unknown" as const, + detail: [ + `Provider checks used this terminal's environment; what the server ${client} starts receives is unknown.`, + ...(unrecorded.length === 0 + ? [] + : [`Its registration does not record ${unrecorded.join(", ")}.`]), + ...(differing.length === 0 + ? [] + : [`It records other values for ${differing.join(", ")}.`]), + ...(unresolved.length === 0 + ? [] + : [`REA could not resolve its ${unresolved.join(", ")}.`]), + ].join(" "), + details: { + effective_environment: "unknown", + providers: selections + .filter(({ settings: selected }) => + names.some((name) => selected[name] !== undefined), + ) + .map(({ provider }) => provider), + }, + remediation: `Preview rea setup --client ${client} --skill=false --dry-run from this terminal, apply it to record these settings and restart the client, or read binary_session result.tool_availability in the client.`, + }, + ]; + }); + const closeDoctorHost = async ( host: DoctorHost, ): Promise => { @@ -369,12 +450,19 @@ const doctorReportWithCleanup = ( const collectDoctorIdentity = async ( host: DoctorHost, scope: DoctorScopeReport, + selections: readonly ProviderSettingSelection[], ): Promise<{ readonly checks: readonly DoctorCheck[]; readonly value: DoctorIdentity; }> => { const installationPaths = (await host.installationPaths?.()) ?? []; - const registrations = (await host.clientRegistrations?.(scope.clients)) ?? []; + const registrations = + (await host.clientRegistrations?.( + scope.clients, + Object.fromEntries( + selections.flatMap(({ settings }) => Object.entries(settings)), + ), + )) ?? []; const skillClientIds = scope.skill_clients ?? (scope.clients.length > 0 ? scope.clients : undefined); @@ -786,12 +874,20 @@ export const systemDoctorHost = ( throw failure; } }, - clientRegistrations: (selectedClientIds) => + clientRegistrations: (selectedClientIds, providerSettings) => readClientRegistrationStatuses(homeDirectory, undefined, { platform, environment, resources, ...(selectedClientIds === undefined ? {} : { selectedClientIds }), + ...(providerSettings === undefined + ? {} + : { + providerSettings: registrationSpelledSettings( + providerSettings, + environment, + ), + }), ...(resolveProviderRequestBudget === undefined ? {} : { diff --git a/src/application/DoctorRegistrationEnvironment.test.ts b/src/application/DoctorRegistrationEnvironment.test.ts new file mode 100644 index 0000000000..58844fafbb --- /dev/null +++ b/src/application/DoctorRegistrationEnvironment.test.ts @@ -0,0 +1,184 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createDoctorHostFixture as host } from "./Doctor.fixture.js"; +import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js"; + +const inspection = ( + id: string, + registrationEnvironment: Readonly>, + available = true, +) => ({ + id, + configured: true, + available, + providerVersion: null, + registrationEnvironment, + checks: [], +}); +const recordingHost = (overrides: Partial) => { + const requested: Readonly>[] = []; + return { + requested, + host: host({ + clientRegistrations: (_clients, providerSettings) => { + requested.push(providerSettings ?? {}); + return Promise.resolve([ + { + client: "cursor", + config_path: "/home/user/.cursor/mcp.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }, + ]); + }, + ...overrides, + }), + }; +}; +const selectedProviders = { + providerInspections: () => + Promise.resolve([ + inspection("ghidra", { GHIDRA_INSTALL_DIR: "/opt/ghidra" }), + inspection("jadx", { REA_JADX_MCP_JAR: "/opt/jadx.jar" }), + inspection("kuna", { REA_KUNA_COMMAND: "/opt/kuna" }, false), + ]), +}; +const CHECK = "registration_environment:cursor"; + +it("compares only settings an available provider relied on", async () => { + const { host: selectedHost, requested } = recordingHost(selectedProviders); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([ + { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + REA_JADX_MCP_JAR: "/opt/jadx.jar", + }, + ]); +}); + +it.each([ + [{ providers: ["ghidra"] }, true], + [{ clients: ["cursor"] }, true], + [{ clients: ["codex"] }, false], + [{ providers: ["jadx"] }, false], + [{ skill: true }, false], +])("follows the selected scope %j", async (scope, selected) => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + scope, + ); + const inScope = report.scope_checks.some(({ name }) => name === CHECK); + expect(inScope).toBe(selected); + expect(report.informational_checks.some(({ name }) => name === CHECK)).toBe( + !selected, + ); +}); + +it("does not gate health or name unaffected providers", async () => { + const report = await runDoctor( + undefined, + recordingHost(selectedProviders).host, + { providers: ["ghidra"] }, + ); + expect(report.healthy).toBe(true); + expect(report.checks).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + details: { effective_environment: "unknown", providers: ["ghidra"] }, + }), + ); +}); + +it.each([ + ["/custom/Hopper", { HOPPER_LAUNCHER_PATH: "/custom/Hopper" }], + // A default location is found again without this terminal's environment. + [undefined, {}], +])( + "includes Hopper only for an explicit available path (%s)", + async (configuredHopperPath, expected) => { + const { host: selectedHost, requested } = recordingHost( + configuredHopperPath === undefined ? {} : { configuredHopperPath }, + ); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([expected]); + }, +); + +it("does not include an explicit Hopper path that fails its checks", async () => { + const { host: selectedHost, requested } = recordingHost({ + configuredHopperPath: "/custom/missing", + executable: () => Promise.resolve(false), + }); + await runDoctor(undefined, selectedHost); + expect(requested).toEqual([{}]); +}); + +it("compares a caller selection as setup spells it", async () => { + const home = await createTestTempDirectory("rea-doctor-spelling-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "/current/rea", + args: ["mcp"], + env: { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/selected/alias", + }, + }, + }, + }), + ); + const system = systemDoctorHost({ + environment: { HOME: home, JAVA_HOME: "/selected/alias" }, + }); + const statuses = await system.clientRegistrations?.(["claude_code"], { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + // The provider's observed home; setup records the caller's spelling. + JAVA_HOME: "/observed/jdk", + }); + await system.close?.(); + expect(statuses?.[0]?.client).toBe("claude_code"); + expect(statuses?.[0]).not.toHaveProperty("provider_settings"); +}); + +it("states each comparison result without a claim it did not establish", async () => { + const report = await runDoctor( + undefined, + host({ + clientRegistrations: () => + Promise.resolve([ + { + client: "cursor", + config_path: "/cursor.json", + command: ["/current/rea", "mcp"], + state: "aligned", + remediation: null, + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }, + ]), + }), + ); + const detail = report.checks.find(({ name }) => name === CHECK)?.detail; + expect(detail).toContain("what the server cursor starts receives is unknown"); + expect(detail).toContain("records other values for JAVA_HOME"); + expect(detail).toContain("could not resolve its GHIDRA_INSTALL_DIR"); + expect(detail).not.toContain("does not record"); +}); diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index 1c8b18d836..870562b8c9 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -72,6 +72,9 @@ export const scopeDoctorChecks = ( input.registrations.find((candidate) => candidate.client === client), ), ); + const environmentChecks = input.checks.filter((check) => + registrationEnvironmentInScope(check, input.scope), + ); const skillChecks = input.scope.skill ? [selectedSkillCheck(input.skillState, input.checks)] : []; @@ -111,6 +114,7 @@ export const scopeDoctorChecks = ( ...hopperHostChecks, ...selectedProviderChecks, ...clientChecks, + ...environmentChecks, ...skillChecks, ]; const selectedNames = new Set(scopeChecks.map(({ name }) => name)); @@ -129,6 +133,22 @@ export const scopeDoctorChecks = ( }; }; +/** Follow the caller's question: a named client, or a provider it relies on. */ +const registrationEnvironmentInScope = ( + { name, details }: DoctorCheck, + scope: DoctorScopeReport, +): boolean => { + const [family, client] = name.split(":"); + if (family !== "registration_environment" || client === undefined) + return false; + const providers = details?.providers; + return ( + scope.clients.includes(client) || + (Array.isArray(providers) && + scope.providers.some((provider) => providers.includes(provider))) + ); +}; + const isProviderCheck = ( name: string, inspections: readonly DoctorProviderInspection[] | undefined, diff --git a/src/application/EnhancedToolTypes.ts b/src/application/EnhancedToolTypes.ts index 686a7bab83..c0006a9b9f 100644 --- a/src/application/EnhancedToolTypes.ts +++ b/src/application/EnhancedToolTypes.ts @@ -3,7 +3,7 @@ import type { z } from "zod"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import type { TraceContainingProcedure } from "../domain/native/nativeObservation.js"; +import type { LiteralFeatureReference } from "../domain/native/literalFeatureTrace.js"; import { err, type Result } from "../domain/result.js"; import { enhancedInputSchemas, @@ -27,11 +27,7 @@ export const failedWithPartialResult = ( error: AnalysisError, partialResult: JsonValue, ): PartialEnhancedFailure => ({ ok: false, error, partialResult }); -export type TraceReference = { - readonly target_address: string; - readonly source_address: string; - readonly containing_procedure: TraceContainingProcedure; -}; +export type TraceReference = LiteralFeatureReference; /** One enhanced call whose input was parsed at its owning adapter boundary. */ export type ValidatedEnhancedCall = { diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index a01fb6546b..b0acbd35a4 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -25,7 +25,6 @@ import { } from "../contracts/enhancedInputs.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -91,48 +90,14 @@ type BatchReadyItem = { readonly providerProcedure: string; }; -/** - * Guidance for a batch item whose provider returned null pseudocode, by - * whether that observation reported caveats for the item to carry. - */ -export const NULL_PSEUDOCODE_GUIDANCE = { - reported: { - userMessage: - "The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.", - remediationAction: - "Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.", - }, - none: { - userMessage: - "The provider returned no pseudocode for this function and reported no caveat or reason for it.", - remediationAction: - "The provider gave no further explanation; another provider may decompile this entry.", - }, -} as const; - -/** - * The item error for a provider answer without usable text. Null is the - * provider's observation that it has no pseudocode for this function; empty - * or non-text pseudocode is unreadable output. - */ -const missingPseudocodeError = ( - providerId: string, - pseudocode: JsonValue, - caveats: keyof typeof NULL_PSEUDOCODE_GUIDANCE, -): AnalysisError => - pseudocode === null - ? new AnalysisCapabilityUnavailableError( - providerId, - "procedure_pseudo_code", - "provider returned null pseudocode", - NULL_PSEUDOCODE_GUIDANCE[caveats], - ) - : new AnalysisOutputError( - "procedure_pseudo_code", - typeof pseudocode === "string" - ? "provider returned empty pseudocode" - : "provider returned non-text pseudocode", - ); +/** Empty and non-text answers are malformed; null is a separate observation. */ +const missingPseudocodeError = (pseudocode: JsonValue): AnalysisError => + new AnalysisOutputError( + "procedure_pseudo_code", + typeof pseudocode === "string" + ? "provider returned empty pseudocode" + : "provider returned non-text pseudocode", + ); const isAnalysisCancellation = ( error: AnalysisError, @@ -539,7 +504,7 @@ export class EnhancedTools { }); continue; } - const { result: pseudocode, provider } = observed.value; + const { result: pseudocode } = observed.value; if (typeof pseudocode === "string" && pseudocode.length > 0) { items.push({ address, procedure, status: "ok", pseudocode }); continue; @@ -547,17 +512,21 @@ export class EnhancedTools { // Only this observation can explain its missing text, so its caveats // stay with the item; the batch Evidence still aggregates them. const limitations = [...new Set(observed.value.limitations)]; + if (pseudocode === null) { + items.push({ + address, + procedure, + status: "unavailable", + reason: "no_pseudocode", + ...(limitations.length === 0 ? {} : { limitations }), + }); + continue; + } items.push({ address, procedure, status: "error", - error: projectAnalysisError( - missingPseudocodeError( - provider.id, - pseudocode, - limitations.length === 0 ? "none" : "reported", - ), - ), + error: projectAnalysisError(missingPseudocodeError(pseudocode)), ...(limitations.length === 0 ? {} : { limitations }), }); } diff --git a/src/application/EvidenceBundleCommands.ts b/src/application/EvidenceBundleCommands.ts index d88e555dd5..26bf80e437 100644 --- a/src/application/EvidenceBundleCommands.ts +++ b/src/application/EvidenceBundleCommands.ts @@ -297,10 +297,12 @@ const projectWrite = ( export const inspectEvidenceBundleCommand = async ( input: { readonly path: string; - readonly detail: "complete" | "summary" | "records"; + readonly detail: "complete" | "summary" | "records" | "links"; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, signal?: AbortSignal, ) => { diff --git a/src/application/EvidenceBundleFiles.ts b/src/application/EvidenceBundleFiles.ts index 9fb61ab652..ccb3f350c3 100644 --- a/src/application/EvidenceBundleFiles.ts +++ b/src/application/EvidenceBundleFiles.ts @@ -4,6 +4,7 @@ import { jsonValueValidationIssue } from "../domain/jsonValue.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { requireAuthenticationHeadroom } from "./JsonAuthenticationHeadroom.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { resemblesAnalysisSnapshot } from "../domain/analysisSnapshot.js"; import { describeValidationFailure } from "../domain/validationFailure.js"; import { @@ -157,6 +158,21 @@ export const authenticateEvidenceBundleValue = ( userMessage: failure.message, }), ); + if ( + operation === "import_evidence_bundle" && + typeof value === "object" && + value !== null && + !Object.hasOwn(value, "records") && + resemblesAnalysisSnapshot(value) + ) + return err( + new EvidenceFileError("read", "wrong-kind", { + path: absoluteCallerPath(path), + cause: failure.cause, + userMessage: + "This JSON appears to be an analysis snapshot rather than an Evidence bundle. Its snapshot content has not been validated. To restore it, use a matching target, provider and analysis profile: call open_binary with snapshot_path set to this file, or pass --snapshot to a compatible CLI analysis command (which loads and updates the snapshot). Alternatively, supply a bundle from export_evidence_bundle.", + }), + ); return err( new EvidenceIntegrityError("Evidence bundle validation failed", { cause: failure.cause, diff --git a/src/application/ReferenceSourceImportTypes.ts b/src/application/ReferenceSourceImportTypes.ts index d9c2324b74..34ccdbd24e 100644 --- a/src/application/ReferenceSourceImportTypes.ts +++ b/src/application/ReferenceSourceImportTypes.ts @@ -125,6 +125,8 @@ export interface ReferenceSourceImportOptions { export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [ ".git/", ".git/hooks/", + // Match pointer files after directory rules to preserve their provenance. + ".git", "node_modules/", "dist/", "build/", diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index 527f213856..2b7dd3a07c 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -301,10 +301,13 @@ describe("setup client selection", () => { configPath: "/codex.toml", format: "toml" as const, }; - host.clients = [codex]; + host.clients = [ + codex, + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; host.availableClients = [ { name: "cursor", configPath: "/cursor.json" }, - codex, + ...host.clients, ]; const result = await runSetup( @@ -331,6 +334,31 @@ describe("setup client selection", () => { expect(host.skillInstalls).toBe(0); }); + it.each([undefined, false])( + "omits manual clients from automatic readiness while preserving detection (skill: %s)", + async (installSkill) => { + const host = new FakeSetupHost(); + host.clients = [ + { name: "grok_bot", configPath: "/grokbot", format: "unsupported" }, + ]; + const result = await runSetup( + { + ...options(true), + allDetectedClients: true, + ...(installSkill === undefined ? {} : { installSkill }), + }, + host, + ); + expect(result.status).toBe("ready"); + expect(result.doctor.scope.clients).toEqual([]); + expect(result.clientStates).toMatchObject([ + { client: { name: "grok_bot" }, detected: true }, + ]); + expect(result.clients).toEqual({}); + expect(host.configurations).toBe(0); + }, + ); + it.each([true, false])( "handles repeated clients once in first-selected order (dry run: %s)", async (dryRun) => { diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 393ebf52c6..242f0b148d 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -67,6 +67,7 @@ import type { SetupProviderEnvironment, } from "./SetupTypes.js"; import { + compactInputSchemaByDefault, registrationWriteBlocker, type SetupClient, } from "./SupportedClients.js"; @@ -936,7 +937,7 @@ const clientConfigurationDesired = async ( ); if (!environment.ok) throw environment.error; const registrationEnvironment = - client.name === "kimi" && + compactInputSchemaByDefault(client) && snapshotEnvironment(environment.value).REA_MCP_INPUT_SCHEMA_PROFILE === undefined ? { diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index cf6da52fc8..5cc3762841 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -139,7 +139,9 @@ export const discoverSetupState = async (input: { installHopper, hopperInstall, clients, - detectedClientIds: [...detectedIds], + detectedClientIds: detectedClients + .filter(({ format }) => format !== "unsupported") + .map(({ name }) => name), defaultClientIds, clientStates, ...(linuxDistribution?.packageFamily === undefined diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 401e18806d..335c86488a 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -107,3 +107,17 @@ export const providerRegistrationEnvironment = ( ) .sort(([left], [right]) => compareUnicodeCodePoints(left, right)), ); + +/** Spell selected settings as setup records them: an explicit selection wins. */ +export const registrationSpelledSettings = ( + settings: Readonly>, + environment: Readonly>, +): Readonly> => { + const explicit = explicitSetupRegistrationEnvironment(environment); + return Object.fromEntries( + Object.entries(settings).map(([key, value]) => [ + key, + explicit[key] ?? value, + ]), + ); +}; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 2dadce7811..7ee94bf8a0 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,6 +1,6 @@ import { dirname, isAbsolute, join, resolve, win32 } from "node:path"; import { fileURLToPath } from "node:url"; -import { lstatSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { err, ok, type Result } from "../domain/result.js"; import { HermesClientPathError, @@ -59,6 +59,7 @@ export interface SetupClient { | "omp" | "pi" | "kimi" + | "lm_studio" | "hermes" | "zcode_fallback" | "zcode" @@ -193,6 +194,40 @@ const codexDirectory = ({ home, env }: ClientPathContext): string => const kimiDirectory = ({ home, env }: ClientPathContext): string => env.KIMI_CODE_HOME || join(home, ".kimi-code"); +/** Clients registered with the compact input-schema profile unless one is selected. */ +export const compactInputSchemaByDefault = ({ + name, +}: Pick): boolean => + name === "kimi" || name === "lm_studio"; + +/** + * LM Studio's findLMStudioHome, read-only: a home pointer file wins, then an + * existing ~/.cache/lm-studio, then ~/.lmstudio. Setup never writes the pointer. + */ +const lmStudioLocation = ({ + home, +}: ClientPathContext): Result => { + const pointerPath = join(home, ".lmstudio-home-pointer"); + let directory: string; + try { + directory = readFileSync(pointerPath, "utf8").trim(); + } catch (cause: unknown) { + if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT")) + return err({ + path: pointerPath, + message: `${pointerPath} could not be read, so REA cannot establish which LM Studio home holds mcp.json: ${cause instanceof Error ? cause.message : String(cause)}. Repair access to that file, then rerun.`, + }); + const cache = join(home, ".cache", "lm-studio"); + directory = existsSync(cache) ? cache : join(home, ".lmstudio"); + } + if (!isAbsolute(directory)) + return err({ + path: pointerPath, + message: `${pointerPath} does not hold an absolute LM Studio home path. Correct or remove that file, then rerun.`, + }); + return ok({ configPath: join(directory, "mcp.json"), markerPath: directory }); +}; + const qoderDirectory = ({ home, env }: ClientPathContext): string => env.QODER_CONFIG_DIR || join(home, ".qoder"); @@ -652,6 +687,12 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: [".workbuddy"], format: "json", }, + { + name: "lm_studio", + displayName: "LM Studio", + location: lmStudioLocation, + format: "lm_studio", + }, { name: "grok_build", displayName: "Grok Build", diff --git a/src/application/WorkflowAnalysisProfile.ts b/src/application/WorkflowAnalysisProfile.ts index f06cf5a215..5c54417703 100644 --- a/src/application/WorkflowAnalysisProfile.ts +++ b/src/application/WorkflowAnalysisProfile.ts @@ -13,6 +13,9 @@ export const workflowAnalysisProfile = ( createAnalysisProfile(REA_WORKFLOW_PROVIDER, { upstream_analysis_profile: upstream, source_limitations: "provider-operation-caveats-v1", + ...(operation === "trace_native_values" + ? { value_flow_absence: "recorded-versus-unavailable-v1" } + : {}), ...(operation === "binary_overview" ? { workflow_revision: 5 } : operation === "batch_decompile" @@ -21,5 +24,7 @@ export const workflowAnalysisProfile = ( ? { workflow_revision: 4 } : operation === "get_call_graph" ? { workflow_revision: 3 } - : { workflow_revision: 1 }), + : operation === "trace_native_values" + ? { workflow_revision: 2 } + : { workflow_revision: 1 }), }); diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index deed631640..1f3d647cc7 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -13,7 +13,11 @@ import { createAnalysisExecution, type AnalysisOperationPort, } from "./AnalysisProvider.js"; -import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + AnalysisResourceConstraintError, +} from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; import { functionDossierSchema } from "../domain/native/functionDossier.js"; @@ -111,6 +115,99 @@ it.each([false, true])( const provider = { id: "fixture", name: "Fixture", version: "1" }; +it("retains requested live observations and links their original producer sources", async () => { + const upstream = createEvidence(undefined, provider, { + operation: "health", + parameters: {}, + result: "source", + }); + const result = await executeComposedWorkflow( + { + execute: async () => + ok( + createAnalysisExecution("live observation", provider, { + cachePolicy: "live", + sourceEvidence: [upstream], + }), + ), + }, + async (observed) => { + const value = await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value; + }, + ); + if (!result.ok) throw result.error; + const source = result.sourceEvidence?.find( + (record) => record.operation === "analyze_function", + ); + expect(source).toBeDefined(); + expect(source?.evidence_links).toEqual([upstream.evidence_id]); + expect(result.value.result).toEqual({ source: source?.evidence_id }); + expect(result.value.evidenceLinks).toContain(source?.evidence_id); + expect(result.value.cachePolicy).toBe("live"); +}); + +it("keeps an existing session-issued source identity on replay", async () => { + const original = createEvidence(undefined, provider, { + operation: "analyze_function", + parameters: { procedure: "0x1" }, + result: "original", + }); + const result = await executeComposedWorkflow( + { + execute: async () => + ok({ + ...createAnalysisExecution("original", provider, { + limitations: ["replayed"], + }), + evidenceId: original.evidence_id, + }), + }, + async (observed) => { + const value = await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return value.ok ? ok({ source: value.value.evidenceId ?? null }) : value; + }, + ); + if (!result.ok) throw result.error; + expect(result.value.result).toEqual({ source: original.evidence_id }); + expect(result.value.evidenceLinks).toEqual([original.evidence_id]); + expect(result.sourceEvidence).toBeUndefined(); +}); + +it("retains a completed requested source if the composed workflow is cancelled", async () => { + const result = await executeComposedWorkflow( + { + execute: async () => ok(createAnalysisExecution("observed", provider)), + }, + async (observed) => { + await observed.execute( + "analyze_function", + { procedure: "0x1" }, + { + retainEvidence: true, + }, + ); + return err(new AnalysisCancelledError("trace_native_values")); + }, + ); + expect(result.ok).toBe(false); + expect(result.sourceEvidence?.map((record) => record.operation)).toEqual([ + "analyze_function", + ]); +}); + it("keeps Evidence identity stable when dependent branches finish in opposite orders", async () => { const run = async (order: readonly number[]) => { const gate = () => { @@ -578,6 +675,71 @@ describe("workflow residual question retention", () => { }); }); +it("reports a retained workflow and the committed prefix when Unknown publication fails", () => { + const records = new InvestigationRecords(); + const result = { residual_unknowns: ["First?", "First?", "Second?"] }; + const evidence = createWorkflowEvidence({ + target: undefined, + operation: "trace_feature", + parameters: {}, + result, + upstreamProfile: undefined, + upstreamLimitations: [], + }); + expect(records.recordEvidence(evidence).ok).toBe(true); + const failure = new AnalysisResourceConstraintError( + "record_unknown", + "capacity", + "full", + null, + ); + const outcome = recordWorkflowUnknowns({ + name: "trace_feature", + result, + evidenceId: evidence.evidence_id, + evidenceRetained: true, + recordUnknown: (input) => + input.question.startsWith("Second?") + ? err(failure) + : records.recordUnknown(input), + }); + expect(outcome.ok).toBe(false); + if (outcome.ok) throw new Error("expected failure"); + const projected = projectAnalysisError(outcome.error); + expect(projected.details?.workflow_unknown_publication).toEqual({ + evidence_id: evidence.evidence_id, + evidence_retention: "retained", + total_questions: 3, + created_questions: 1, + existing_questions: 1, + next_question_index: 2, + }); + expect(projected.remediation.action).toContain("get_evidence_bundle"); + const invocation = projectAnalysisError( + outcome.error.retainPartialObservation(evidence), + "invocation", + ); + expect(invocation.details?.workflow_unknown_publication).toMatchObject({ + evidence_retention: "invocation-only", + }); + expect(invocation.details?.partial_observation).toEqual(evidence); + expect(invocation.remediation.action).not.toContain("get_evidence_bundle"); + expect(records.listUnknowns()).toHaveLength(1); + expect(records.evidenceById(evidence.evidence_id)?.normalized_result).toEqual( + result, + ); + expect( + recordWorkflowUnknowns({ + name: "trace_feature", + result, + evidenceId: evidence.evidence_id, + evidenceRetained: true, + recordUnknown: (input) => records.recordUnknown(input), + }).ok, + ).toBe(true); + expect(records.listUnknowns()).toHaveLength(2); +}); + it.each([ " Which boundary? ", "\nWhich boundary?\t", diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 2623fa1f4a..0d30237cbd 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -1,15 +1,17 @@ import { AnalysisWorkScope } from "./AnalysisWorkScope.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { jsonStringByteLength } from "../domain/jsonStringByteLength.js"; +import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js"; import { REA_WORKFLOW_PROVIDER } from "../domain/investigationProviders.js"; import { workflowAnalysisProfile } from "./WorkflowAnalysisProfile.js"; import type { AnalysisOperation, AnalysisOperationPort, } from "./AnalysisProvider.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import type { EnhancedResult } from "./EnhancedToolTypes.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js"; import { analysisProfileSchema, @@ -101,12 +103,10 @@ export const executeComposedWorkflow = async ( const observed: AnalysisOperationPort = { resourceScope: analysis.resourceScope, execute: async (operation, parameters, options) => { - const execution = await analysis.execute(operation, parameters, { + let execution = await analysis.execute(operation, parameters, { ...options, workScope, }); - if (execution.ok && execution.value.evidenceId !== undefined) - evidenceLinks.add(execution.value.evidenceId); if (execution.ok && execution.value.sourceEvidence !== undefined) { const recorded = recordEvidenceSources( (source) => sourceLedger.record(source), @@ -114,6 +114,26 @@ export const executeComposedWorkflow = async ( ); if (!recorded.ok) return recorded; } + if ( + execution.ok && + options?.retainEvidence === true && + execution.value.evidenceId === undefined + ) { + const source = createAnalysisExecutionEvidence( + operation, + parameters, + execution.value, + "observed", + ); + const recorded = recordEvidenceSources( + (evidence) => sourceLedger.record(evidence), + [source], + ); + if (!recorded.ok) return recorded; + execution = ok({ ...execution.value, evidenceId: source.evidence_id }); + } + if (execution.ok && execution.value.evidenceId !== undefined) + evidenceLinks.add(execution.value.evidenceId); if (execution.ok && execution.value.cachePolicy === "live") cachePolicy = "live"; if (execution.ok) @@ -309,6 +329,8 @@ interface WorkflowUnknownInput { readonly result: JsonValue; readonly evidenceId: string; readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined; + /** The caller confirmed the completed Evidence is in its investigation. */ + readonly evidenceRetained?: boolean; } /** Retain residual workflow questions using the shared investigation policy. */ @@ -317,6 +339,7 @@ export const recordWorkflowUnknowns = ({ result, evidenceId, recordUnknown, + evidenceRetained, }: WorkflowUnknownInput): | ReturnType | { readonly ok: true; readonly value: null } => { @@ -331,7 +354,27 @@ export const recordWorkflowUnknowns = ({ !Array.isArray(result.residual_unknowns) ) return { ok: true, value: null }; - for (const question of result.residual_unknowns) { + const questions = result.residual_unknowns; + const total = questions.reduce( + (count, value) => count + (typeof value === "string" ? 1 : 0), + 0, + ); + let created = 0; + let existing = 0; + const stopped = (error: AnalysisError, index: number) => + err( + error.retainWorkflowUnknownPublication({ + evidence_id: evidenceId, + evidence_retention: + evidenceRetained === true ? "retained" : "unverified", + total_questions: total, + created_questions: created, + existing_questions: existing, + next_question_index: index, + }), + ); + for (let index = 0; index < questions.length; index += 1) { + const question = questions[index]; if (typeof question !== "string") continue; const input = workflowUnknownInput(evidenceId, { question, @@ -351,16 +394,15 @@ export const recordWorkflowUnknowns = ({ ], relationships: [], }); - if (!input.ok) return input; + if (!input.ok) return stopped(input.error, index); const recorded = recordUnknown(input.value); - if ( - !recorded.ok && - !( - recorded.error instanceof UnknownRegistryError && - recorded.error.reason === "already-exists" - ) + if (recorded.ok) created += 1; + else if ( + recorded.error instanceof UnknownRegistryError && + recorded.error.reason === "already-exists" ) - return recorded; + existing += 1; + else return stopped(recorded.error, index); } return { ok: true, value: null }; }; diff --git a/src/application/XrefsAddressResolution.ts b/src/application/XrefsAddressResolution.ts index f28cf62506..8348c12f66 100644 --- a/src/application/XrefsAddressResolution.ts +++ b/src/application/XrefsAddressResolution.ts @@ -21,13 +21,21 @@ export const resolveXrefsAddress = async ( return named.value !== null ? ok(named.value) : err( - new AnalysisInputError("xrefs", undefined, [ + new AnalysisInputError( + "xrefs", { - path: ["address"], - reason: "invalid_value", - message: `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.`, + conformsToInputContract: true, + remediationAction: + "For a function, use rea search --kind procedures to find an analyzed address. For exports, inspect-macho (Mach-O) or inspect-native-exports (PE/ELF) may help; exports can omit local symbols and use different address coordinates. Verify the address in the selected provider, then retry xrefs.", }, - ]), + [ + { + path: ["address"], + reason: "invalid_value", + message: `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.`, + }, + ], + ), ); }; @@ -90,12 +98,20 @@ export const resolveXrefsName = async ( const [address] = addresses; if (addresses.size <= 1) return ok(address ?? null); return err( - new AnalysisInputError(operation, undefined, [ + new AnalysisInputError( + operation, { - path: [operation === "xrefs" ? "address" : "name"], - reason: "invalid_value", - message: `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].sort().join(", ")}. Select an exact address.`, + conformsToInputContract: true, + remediationAction: + "Select an exact address from the candidates and call xrefs with that address.", }, - ]), + [ + { + path: [operation === "xrefs" ? "address" : "name"], + reason: "invalid_value", + message: `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].sort().join(", ")}. Select an exact address.`, + }, + ], + ), ); }; diff --git a/src/application/analysisView/NativeLoadImageViews.test.ts b/src/application/analysisView/NativeLoadImageViews.test.ts index c1616d57c2..2c391694ea 100644 --- a/src/application/analysisView/NativeLoadImageViews.test.ts +++ b/src/application/analysisView/NativeLoadImageViews.test.ts @@ -208,6 +208,13 @@ it.each(["checks", "sections"])( await view(source(), { kind: "native-load-image", collection }), ).toMatchObject({ items: { collection, rows: null }, + coverage: { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }, unknowns: expect.arrayContaining([ "Null collection counts or rows mean not recorded or not applicable, not observed absence.", ]), diff --git a/src/application/analysisView/NativeObservationViews.test.ts b/src/application/analysisView/NativeObservationViews.test.ts index c3e2de8d8d..c9c16e99ef 100644 --- a/src/application/analysisView/NativeObservationViews.test.ts +++ b/src/application/analysisView/NativeObservationViews.test.ts @@ -121,53 +121,56 @@ const graph = (): NativeCallGraph => limitations: ["Graph caveat", "Graph caveat"], }); -it("returns useful caller/callee facts with exact source context identity and partial coverage", async () => { - const source = parent("procedure_callees", [ - "ram:0x0010", - "ram:0x0010", - "ram:0x0020", - ]); - const before = JSON.stringify(source); - const summary = await project(source, { kind: "summary" }); - expect(summary.value).toMatchObject({ - kind: "summary", - parent_evidence_id: source.evidence_id, - source_provider: source.provider, - source_parameters: source.parameters, - source_analysis_profile_digest: source.analysis_profile?.digest, - source_context: { - evidence_limitation_count: 1, - result_limitation_count: null, - }, - summary: { observations: 3 }, - items: { - type: "addresses", - rows: [ - { source_index: 0, address: "ram:0x0010" }, - { source_index: 1, address: "ram:0x0010" }, - { source_index: 2, address: "ram:0x0020" }, - ], - }, - }); - expect(summary.evidence.relationship_coverage).toEqual( - source.relationship_coverage, - ); - const page = await project(source, { - kind: "native-observation", - offset: 1, - limit: 1, - }); - expect(page.value).toMatchObject({ - items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, - coverage: { total: 3, next_offset: 2, exhausted: false }, - }); - expect(page.value).toHaveProperty( - "source_context.digest", - contextDigest(summary.value), - ); - expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); - expect(JSON.stringify(source)).toBe(before); -}); +it.each(["procedure_callees", "xrefs"])( + "pages %s addresses with exact source context and duplicate identity", + async (operation) => { + const source = parent(operation, [ + "ram:0x0010", + "ram:0x0010", + "ram:0x0020", + ]); + const before = JSON.stringify(source); + const summary = await project(source, { kind: "summary" }); + expect(summary.value).toMatchObject({ + kind: "summary", + parent_evidence_id: source.evidence_id, + source_provider: source.provider, + source_parameters: source.parameters, + source_analysis_profile_digest: source.analysis_profile?.digest, + source_context: { + evidence_limitation_count: 1, + result_limitation_count: null, + }, + summary: { observations: 3 }, + items: { + type: "addresses", + rows: [ + { source_index: 0, address: "ram:0x0010" }, + { source_index: 1, address: "ram:0x0010" }, + { source_index: 2, address: "ram:0x0020" }, + ], + }, + }); + expect(summary.evidence.relationship_coverage).toEqual( + source.relationship_coverage, + ); + const page = await project(source, { + kind: "native-observation", + offset: 1, + limit: 1, + }); + expect(page.value).toMatchObject({ + items: { rows: [{ source_index: 1, address: "ram:0x0010" }] }, + coverage: { total: 3, next_offset: 2, exhausted: false }, + }); + expect(page.value).toHaveProperty( + "source_context.digest", + contextDigest(summary.value), + ); + expect(JSON.stringify(summary.value)).not.toContain('"analysis_profile":'); + expect(JSON.stringify(source)).toBe(before); + }, +); it("pages complete context fields and changes its digest when source profile or caveats change", async () => { const source = parent("procedure_callers", []); @@ -249,6 +252,13 @@ it("keeps absent profile and inapplicable result caveats distinct from observed ).toMatchObject({ source_context: { result_limitation_count: null }, items: { rows: null }, + coverage: { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }, unknowns: expect.arrayContaining([ expect.stringContaining("not applicable"), ]), diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index d3396d4a6a..40e823c817 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -777,7 +777,9 @@ describe("analysis snapshot cache policy", () => { ); }, ); +}); +describe("document-scoped snapshot admission", () => { it("retains caller observations without a document while refusing to replay them", () => { const descriptor: CapabilityDescriptor = { operation: "procedure_callers", @@ -829,6 +831,35 @@ describe("analysis snapshot cache policy", () => { document: "fixture", }), ).toBe(true); + const ghidra = { + ...pseudocode, + provider: { ...pseudocode.provider, id: "ghidra" }, + }; + expect( + isSnapshotCacheable("procedure_pseudo_code", ghidra, parameters), + ).toBe(false); + expect( + isSnapshotCacheable( + "procedure_pseudo_code", + ghidra, + parameters, + "one-shot-target", + ), + ).toBe(true); + for (const refused of [ + pseudocode, + { ...ghidra, cachePolicy: "live" as const }, + { ...ghidra, effects: { ...ghidra.effects, mutatesArtifact: true } }, + { ...ghidra, effects: { ...ghidra.effects, changesPermissions: true } }, + ]) + expect( + isSnapshotCacheable( + "procedure_pseudo_code", + refused, + parameters, + "one-shot-target", + ), + ).toBe(false); }); }); @@ -1151,6 +1182,71 @@ it("does not replay pre-envelope call graphs under the revised workflow profile" ).toBe(false); }); +it("keeps older value-trace Evidence as history without replaying its source IDs", () => { + const cache = new AnalysisSnapshotCache(); + const target = ANALYSIS_SNAPSHOT_TARGET; + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const operation = "trace_native_values" as const; + const parameters = { procedure: "0x1000" }; + const oldProfile = workflowAnalysisProfile(profile); + const oldResult = { + seed: "0x1000", + target_sha256: target.sha256, + nodes: [], + edges: [], + procedures: [ + { + address: "0x1000", + depth: 0, + evidence_id: `ev_${"a".repeat(64)}`, + provider: profile.provider.id, + analysis_profile_digest: profile.digest, + }, + ], + unknowns: [], + total_nodes: 0, + total_edges: 0, + decompilations: 1, + next_offset: null, + page_truncated: false, + traversal: { status: "complete" }, + truncated: false, + limitations: [], + }; + const execution = { + ...createAnalysisExecution(oldResult, REA_WORKFLOW_PROVIDER, { + subject: target, + }), + analysisProfile: oldProfile, + }; + cache.recordWorkflow({ target, profile, operation, parameters, execution }); + const evidence = createEvidence(target, REA_WORKFLOW_PROVIDER, { + operation, + parameters, + result: oldResult, + confidence: "derived", + analysisProfile: oldProfile, + }); + const exported = cache.export( + target, + profile, + createEvidenceBundle([evidence]), + ); + if (!exported.ok) throw exported.error; + const snapshot = parseAnalysisSnapshot(exported.value); + expect(snapshot.evidence_bundle.records).toContainEqual(evidence); + expect( + snapshotEvidenceForQuery(snapshot, { + target, + bindingProfile: profile, + operation, + parameters, + provider: REA_WORKFLOW_PROVIDER, + evidenceProfile: workflowAnalysisProfile(profile, operation), + }), + ).toBeUndefined(); +}); + it("declines source-bearing snapshot entries while replaying omitted and empty source controls", () => { const cache = new AnalysisSnapshotCache(); const target = ANALYSIS_SNAPSHOT_TARGET; @@ -1261,3 +1357,15 @@ it("revises the trace_feature workflow profile so earlier results are not replay "other workflows keep their profile", ).toEqual(workflowAnalysisProfile(profile)); }); + +it("binds native value-trace absence semantics without changing other workflows", () => { + const profile = ANALYSIS_SNAPSHOT_PROFILE; + const valueTrace = workflowAnalysisProfile(profile, "trace_native_values"); + expect(valueTrace.parameters.value_flow_absence).toBe( + "recorded-versus-unavailable-v1", + ); + expect(valueTrace.digest).not.toBe(workflowAnalysisProfile(profile).digest); + expect(workflowAnalysisProfile(profile, "trace_call_path")).toEqual( + workflowAnalysisProfile(profile), + ); +}); diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 1289c2cb43..49dabfbffc 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -36,6 +36,7 @@ import type { AnalysisExecution, AnalysisOperation, CapabilityDescriptor, + ExecutionOptions, } from "../AnalysisProvider.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../../contracts/officialToolContracts.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; @@ -94,6 +95,16 @@ const replayDocumentSelected = ( !DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string"; +/** Ghidra's private Program belongs to this one-shot target; GUI defaults do not. */ +const isOneShotPseudocode = ( + operation: AnalysisOperation, + descriptor: CapabilityDescriptor | undefined, + context: ExecutionOptions["snapshotContext"], +): boolean => + context === "one-shot-target" && + operation === "procedure_pseudo_code" && + descriptor?.provider.id === "ghidra"; + /** * Caller and callee walks omit `document`. Their observations are still * Evidence. Other document-scoped reads, including `procedure_pseudo_code` @@ -112,12 +123,14 @@ export const isSourceEvidenceRetainable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, + context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => !STATEFUL_OPERATIONS.has(operation) && !RESOURCE_SCOPED_OPERATIONS.has(operation) && (!DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string" || - DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation)) && + DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) || + isOneShotPseudocode(operation, descriptor, context)) && (!CURSOR_DEFAULT_OPERATIONS.has(operation) || typeof parameters.address === "string") && descriptor?.effects.mutatesArtifact === false && @@ -130,9 +143,11 @@ export const isSnapshotCacheable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, + context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => - isSourceEvidenceRetainable(operation, descriptor, parameters) && - replayDocumentSelected(operation, parameters) && + isSourceEvidenceRetainable(operation, descriptor, parameters, context) && + (replayDocumentSelected(operation, parameters) || + isOneShotPseudocode(operation, descriptor, context)) && descriptor.cachePolicy !== "live"; /** Windows Ghidra must admit the selected source through its native snapshot owner. */ @@ -541,6 +556,7 @@ export class AnalysisSnapshotCache { readonly operation: AnalysisOperation; readonly parameters: Readonly>; readonly execution: AnalysisExecution; + readonly evidence?: Evidence; }): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); @@ -583,6 +599,9 @@ export class AnalysisSnapshotCache { else this.#replayBindings.set(entry.query_id, { evidenceId: execution.evidenceId, + ...(input.evidence === undefined + ? {} + : { sourceEvidence: Object.freeze([input.evidence]) }), }); } diff --git a/src/application/binary/AnalysisSnapshotReplayBinding.ts b/src/application/binary/AnalysisSnapshotReplayBinding.ts index b80a1f869f..5d6dfc2027 100644 --- a/src/application/binary/AnalysisSnapshotReplayBinding.ts +++ b/src/application/binary/AnalysisSnapshotReplayBinding.ts @@ -11,8 +11,6 @@ export const snapshotReplayBinding = ( evidence: Evidence, records: ReadonlyMap, ): SnapshotReplayBinding | null => { - if (evidence.evidence_links.length === 0) - return { evidenceId: evidence.evidence_id }; const sources: Evidence[] = []; for (const id of evidence.evidence_links) { const source = records.get(id); @@ -20,6 +18,9 @@ export const snapshotReplayBinding = ( if (source === undefined) return null; sources.push(source); } + // The replay is a new observation with its own replay limitation. Retain + // its original record as well as the original's immediate sources. + sources.push(evidence); return { evidenceId: evidence.evidence_id, sourceEvidence: Object.freeze(sources), diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 84369c5253..b6d46e949e 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -35,6 +35,8 @@ import type { } from "../AnalysisProvider.js"; import type { BinarySessionPort, + BinaryOpenResult, + OpenSelectionReceipt, SavedAnalysisSnapshot, } from "./BinarySessionPort.js"; import { @@ -478,7 +480,7 @@ export class BinarySession open( path: string, options: BinarySessionOpenOptions = {}, - ): Promise> { + ): Promise { return this.#open( (admitted) => resolveSessionOpen({ @@ -498,7 +500,7 @@ export class BinarySession openResolvedTarget( resolved: Pick, options: Pick = {}, - ): Promise> { + ): Promise { return this.#open( () => Promise.resolve( @@ -519,20 +521,44 @@ export class BinarySession admitted: BinarySessionOpenOptions, ) => Promise>, options: BinarySessionOpenOptions, - ): Promise> { + ): Promise { return this.#serialize(async (admittedThrough) => { - const refused = this.#targetAcquisitionRefused("open_binary"); - if (refused !== undefined) return refused; - const admitted = await this.#withLoadedSnapshot(options); - if (!admitted.ok) return admitted; - return this.#openWithinTransition( - admittedThrough, - () => resolve(admitted.value), - admitted.value, - ); + const attempt = async (): Promise< + Result + > => { + const refused = this.#targetAcquisitionRefused("open_binary"); + if (refused !== undefined) return refused; + const admitted = await this.#withLoadedSnapshot(options); + if (!admitted.ok) return admitted; + return this.#openWithinTransition( + admittedThrough, + () => resolve(admitted.value), + admitted.value, + ); + }; + const result = await attempt(); + return result.ok + ? result + : { ...result, selection: this.#openSelectionReceipt() }; }); } + #openSelectionReceipt(): OpenSelectionReceipt { + if (this.#active !== undefined) + return { + kind: "binary", + path: this.#active.target.path, + sha256: this.#active.target.sha256, + }; + if (this.#activeBundleResources !== undefined) + return { + kind: "resources", + path: this.#activeBundleResources.root.canonicalPath, + caller_path: this.#activeBundleResources.root.callerPath, + }; + return { kind: "none" }; + } + /** Read the staged snapshot only after earlier lifecycle transitions have settled. */ async #withLoadedSnapshot( options: BinarySessionOpenOptions, @@ -1418,6 +1444,7 @@ export class BinarySession active: this.#active, operation: name, parameters: arguments_, + snapshotContext: options?.snapshotContext, nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true, unboundOperationError: (operation, route) => this.#providerRouter.unboundOperationError(operation, route), @@ -1479,7 +1506,12 @@ export class BinarySession } if ( execution.ok && - isSourceEvidenceRetainable(name, capability, arguments_) && + isSourceEvidenceRetainable( + name, + capability, + arguments_, + options?.snapshotContext, + ) && profile !== undefined ) { const evidence = createAnalysisExecutionEvidence( @@ -1505,6 +1537,7 @@ export class BinarySession operation: name, parameters: arguments_, execution: retained, + evidence: this.evidenceForAnalysis(evidence.evidence_id) ?? evidence, }); return ok(retained); } diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts index 8c07ef83ca..83c1fa247c 100644 --- a/src/application/binary/BinarySessionBundleResources.test.ts +++ b/src/application/binary/BinarySessionBundleResources.test.ts @@ -157,6 +157,13 @@ it("opens a resource-only bundle without executable identity and keeps snapshot session_kind: "bundle_resources", resource_root: { canonical_path: opened.value.canonicalPath }, }); + const rejectedBinary = await session.open(join(rootPath, "missing-binary")); + if (rejectedBinary.ok) throw new Error("Expected a missing binary refusal"); + expect(rejectedBinary.selection).toEqual({ + kind: "resources", + path: opened.value.canonicalPath, + caller_path: opened.value.callerPath, + }); const snapshot = session.exportAnalysisSnapshot(); if (snapshot.ok) throw new Error("resource session exported a binary snapshot"); @@ -652,10 +659,15 @@ it("keeps an earlier resource close failure primary when binary candidate cleanu const result = await opening; expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected failed binary open"); + expect(result.selection).toEqual({ kind: "none" }); expect(result.error).toBe(originalFailure); expect(result.error.cleanupPrimary).toBeUndefined(); expect(binaryCloseAttempts).toBe(1); expect(resourceCloseAttempts).toBe(1); + expect(await session.status()).toMatchObject({ + open: false, + cleanup_pending: true, + }); expect(await session.close()).toMatchObject({ ok: true }); expect(resourceCloseAttempts).toBe(2); }); diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 37c1f2551b..fa0703f93b 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -15,6 +15,7 @@ import type { AnalysisExecution, AnalysisOperation, CapabilityDescriptor, + ExecutionOptions, } from "../AnalysisProvider.js"; import { isSnapshotCacheable, @@ -41,6 +42,7 @@ interface PrepareSessionExecutionInput { readonly active: ActiveExecutionBinding | undefined; readonly operation: AnalysisOperation; readonly parameters: Readonly>; + readonly snapshotContext?: ExecutionOptions["snapshotContext"]; readonly nativeSourceAdmitted: boolean; readonly unboundOperationError: ( operation: AnalysisOperation, @@ -76,6 +78,14 @@ export const prepareSessionExecution = ( active.route.binding?.identity.id ?? active.route.identity.id, operation, capability?.reason ?? "operation is not declared by this provider", + { + ...(capability?.userMessage === undefined + ? {} + : { userMessage: capability.userMessage }), + ...(capability?.remediation === undefined + ? {} + : { remediationAction: capability.remediation }), + }, ), ); } @@ -86,7 +96,12 @@ export const prepareSessionExecution = ( : undefined; const cacheable = profile !== undefined && - isSnapshotCacheable(operation, capability, parameters); + isSnapshotCacheable( + operation, + capability, + parameters, + input.snapshotContext, + ); const cached = cacheable && (!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) || diff --git a/src/application/binary/BinarySessionLiveness.test.ts b/src/application/binary/BinarySessionLiveness.test.ts index 9bf4bb7357..91b4ad06bf 100644 --- a/src/application/binary/BinarySessionLiveness.test.ts +++ b/src/application/binary/BinarySessionLiveness.test.ts @@ -115,9 +115,14 @@ it("keeps unbound observations out of persisted target snapshots", async () => { const parameters = { document: "0", address: "0x1000" }; expect((await session.open(process.execPath)).ok).toBe(true); const bound = await session.execute("address_name", parameters); - expect(bound.ok).toBe(true); + if (!bound.ok) throw bound.error; const child = deep.created[0]; - if (child === undefined || !bound.ok) throw new Error("Expected bound read"); + if (child === undefined) throw new Error("Expected bound read"); + const liveReplay = await session.execute("address_name", parameters); + if (!liveReplay.ok) throw liveReplay.error; + expect( + liveReplay.value.sourceEvidence?.map((source) => source.evidence_id), + ).toEqual([bound.value.evidenceId]); child.execute = () => Promise.resolve( ok( @@ -169,13 +174,14 @@ it("keeps unbound observations out of persisted target snapshots", async () => { ).ok, ).toBe(true); const replay = await session.execute("address_name", parameters); - expect(replay.ok).toBe(true); - if (replay.ok) { - expect(replay.value.evidenceId).toBe(bound.value.evidenceId); - expect(replay.value.limitations).toContain( - "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", - ); - } + if (!replay.ok) throw replay.error; + expect(replay.value.evidenceId).toBe(bound.value.evidenceId); + expect( + replay.value.sourceEvidence?.map((source) => source.evidence_id), + ).toEqual([bound.value.evidenceId]); + expect(replay.value.limitations).toContain( + "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", + ); }); describe("BinarySession open liveness", () => { diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 81b4655c4f..32cb1787aa 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -12,8 +12,8 @@ import { snapshotMatchesTarget, snapshotTarget, type SnapshotBindingMismatch, + type AnalysisSnapshot, } from "../../domain/analysisSnapshot.js"; -import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { diff --git a/src/application/binary/BinarySessionPort.ts b/src/application/binary/BinarySessionPort.ts index 58ab53af8f..bf88adb4e4 100644 --- a/src/application/binary/BinarySessionPort.ts +++ b/src/application/binary/BinarySessionPort.ts @@ -30,6 +30,24 @@ import type { import type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js"; export type { SavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js"; +/** Selection observed at the end of one serialized open attempt. */ +export type OpenSelectionReceipt = + | { readonly kind: "binary"; readonly path: string; readonly sha256: string } + | { + readonly kind: "resources"; + readonly path: string; + readonly caller_path: string; + } + | { readonly kind: "none" }; + +export type BinaryOpenResult = + | { readonly ok: true; readonly value: BinaryTarget } + | { + readonly ok: false; + readonly error: AnalysisError; + readonly selection: OpenSelectionReceipt; + }; + /** Target lifecycle used by CLI and MCP without exposing a concrete provider. */ export interface BinarySessionPort extends AnalysisOperationPort, InvestigationRecordPort { @@ -72,7 +90,7 @@ export interface BinarySessionPort readonly loadSnapshot?: AnalysisSnapshotLoader; readonly providerId?: AnalysisProviderSelector; }, - ): Promise>; + ): Promise; /** Open a resource-only app root without selecting or inventing an executable target. */ openBundleResources( path: string, diff --git a/src/application/binary/BinarySessionRestoration.test.ts b/src/application/binary/BinarySessionRestoration.test.ts index 6720d03d99..a09e473f5a 100644 --- a/src/application/binary/BinarySessionRestoration.test.ts +++ b/src/application/binary/BinarySessionRestoration.test.ts @@ -98,6 +98,7 @@ it.each([ ); expect(result.ok).toBe(false); if (result.ok) return; + expect(result.selection).toEqual({ kind: "none" }); expect(session.activeTarget()).toBeUndefined(); expect(await session.status()).toMatchObject({ open: false, @@ -119,6 +120,7 @@ it.each([ expect(projectAnalysisError(result.error).code).toBe( cancelled ? "cancelled" : "unreadable_output", ); + expect(result.selection).toEqual({ kind: "none" }); expect(projectAnalysisError(result.error)).toMatchObject({ details: { diagnostics: { diff --git a/src/application/binary/SessionRuntimeAvailability.ts b/src/application/binary/SessionRuntimeAvailability.ts index 7519865a4b..748c65a5e9 100644 --- a/src/application/binary/SessionRuntimeAvailability.ts +++ b/src/application/binary/SessionRuntimeAvailability.ts @@ -34,7 +34,7 @@ export const runtimeFailureReason = ( failure: AnalysisError, ): string | undefined => { if (failure._tag === "AnalysisCapabilityUnavailableError") - return failure.message; + return failure.userMessage ?? failure.message; return PROVIDER_WIDE_FAILURES.has(failure._tag) ? `${failure._tag}: ${failure.message}` : undefined; diff --git a/src/application/binary/TargetFormatVocabularies.test.ts b/src/application/binary/TargetFormatVocabularies.test.ts index 8aef2e212f..299093c9d1 100644 --- a/src/application/binary/TargetFormatVocabularies.test.ts +++ b/src/application/binary/TargetFormatVocabularies.test.ts @@ -120,6 +120,7 @@ const TARGETS: TargetFixtures = { ines: { ...archive, format: "ines" }, "godot-pck": { ...archive, format: "godot-pck" }, unityfs: { ...archive, format: "unityfs" }, + wxapkg: { ...archive, format: "wxapkg" }, uimage: { ...archive, format: "uimage" }, uf2: { ...archive, format: "uf2" }, dfuse: { ...archive, format: "dfuse" }, diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index f8a4c6b878..c087a83a64 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -58,6 +58,7 @@ it("records the decoded layout once in its Evidence", async () => { it.each([ [{ machine: "EM_X86_64", bits: 64, byte_order: "little" }, "x86_64"], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, "arm64"], [{ machine: "EM_386", bits: 32, byte_order: "little" }, "x86"], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, "arm"], [{ machine: "EM_MIPS", bits: 32, byte_order: "big" }, "mips"], diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 87dd9723b6..757ab7eb6b 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -14,6 +14,7 @@ import { parseAnalysisInput } from "../AnalysisInput.js"; const OPERATION = "inspect_binary_layout"; const EVIDENCE_ARCHITECTURES = { EM_X86_64: "x86_64", + EM_AARCH64: "arm64", EM_386: "x86", EM_ARM: "arm", EM_MIPS: "mips", diff --git a/src/application/decompileDirectory/decompileAll.ts b/src/application/decompileDirectory/decompileAll.ts index 94d5752b63..7b8b24fd52 100644 --- a/src/application/decompileDirectory/decompileAll.ts +++ b/src/application/decompileDirectory/decompileAll.ts @@ -535,6 +535,12 @@ const publishOne = async (input: { const caveats = [ ...new Set([...infoCaveats, ...decompiled.value.limitations]), ]; + const observationSource: NonNullable< + DecompileDirectoryFunctionRecord["provider_source"] + > = + decompiled.value.sourceDescriptor === undefined + ? { status: "unknown", reason: "source_not_available" } + : { status: "reported", source: decompiled.value.sourceDescriptor }; const pseudocode = decompiled.value.result; if (typeof pseudocode !== "string" || pseudocode.length === 0) { const reason = pseudocodeFailure(pseudocode); @@ -543,6 +549,7 @@ const publishOne = async (input: { result: await input.session.append( failureRecord(input.procedure, body.fact, reason, { limitations: caveats, + providerSource: observationSource, }), ), bodySupport: body.support, @@ -580,6 +587,7 @@ const publishOne = async (input: { failureRecord(input.procedure, body.fact, reason, { relativePath, limitations: caveats, + providerSource: observationSource, }), ), bodySupport: body.support, @@ -591,6 +599,11 @@ const publishOne = async (input: { status: "written", relative_path: relativePath, sha256: committed.sha256, + // Matching existing bytes prove equality, not who originally wrote them. + provider_source: + committed.status === "matched" + ? { status: "unknown", reason: "existing_file_origin_unknown" } + : observationSource, body: bodyRecord(body.fact), // The header may truncate; the journal keeps the complete list. ...(caveats.length === 0 ? {} : { limitations: caveats }), @@ -889,6 +902,7 @@ const failureRecord = ( readonly relativePath?: string; /** Caveats of the observations this attempt used. */ readonly limitations?: readonly string[]; + readonly providerSource?: DecompileDirectoryFunctionRecord["provider_source"]; } = {}, ): DecompileDirectoryFunctionRecord => ({ address: procedure.address, @@ -896,6 +910,9 @@ const failureRecord = ( status: "failed", body: bodyRecord(body), failure: reason, + ...(options.providerSource === undefined + ? {} + : { provider_source: options.providerSource }), ...(options.relativePath === undefined ? {} : { relative_path: options.relativePath }), diff --git a/src/application/decompileDirectory/exportTree.ts b/src/application/decompileDirectory/exportTree.ts index e28229bd36..3954fd2ce2 100644 --- a/src/application/decompileDirectory/exportTree.ts +++ b/src/application/decompileDirectory/exportTree.ts @@ -802,11 +802,19 @@ const journalIdentityMismatch = ( ); }; -const incompatibleJournal = (): AnalysisInputError => - decompileInputError( - "output_directory", - "The decompile journal is not a compatible export.", +const incompatibleJournal = (): AnalysisInputError => { + const message = "The decompile journal is not a compatible export."; + return new AnalysisInputError( + "decompile_all", + { + userMessage: message, + conformsToInputContract: true, + remediationAction: + "Inspect the journal and use the REA version that created it, or choose a new output directory. The journal may be damaged or from an unsupported version; existing files were left unchanged.", + }, + [{ path: ["output_directory"], reason: "invalid_value", message }], ); +}; const parseJson = (line: string | undefined): unknown => { if (line === undefined) return undefined; diff --git a/src/application/environment/environmentCompare.test.ts b/src/application/environment/environmentCompare.test.ts index c69917b786..fea061cf5f 100644 --- a/src/application/environment/environmentCompare.test.ts +++ b/src/application/environment/environmentCompare.test.ts @@ -64,6 +64,56 @@ const compare = (...probes: (readonly TimedCaptureRow[])[]) => { return report.value; }; +describe("step difference coverage", () => { + it("keeps categories from differences beyond the returned prefix", () => { + const fields = Object.fromEntries( + Array.from({ length: 20 }, (_, index) => [`field${String(index)}`, 0]), + ); + const capture = timedCaptureEvidence([["GET", "/api/config", fields]]); + const served = environmentStateServeFixture(capture); + const report = compareEnvironment({ + source: environmentServeSource(served), + captureValue: capture, + probes: [ + { + label: "changed", + value: timedCaptureEvidence([ + [ + "GET", + "/api/config", + { + ...Object.fromEntries( + Object.keys(fields).map((key) => [key, 1]), + ), + z_missing: true, + }, + ], + ]), + }, + ], + operation: "environment-compare", + }); + if (!report.ok) throw report.error; + const step = report.value.probes[0]!.results[0]!; + expect(step).toMatchObject({ + equal: false, + equal_ignoring_order: false, + categories: ["value", "missing-field"], + differences_total: 21, + differences_omitted: 1, + differences_truncated: true, + }); + expect(step.differences).toHaveLength(20); + expect(step.differences.every(({ category }) => category === "value")).toBe( + true, + ); + expect(report.value.summary.categories).toMatchObject({ + value: 1, + "missing-field": 1, + }); + }); +}); + describe("matchProbeRequest", () => { it("compares a repeated-query probe against the matching recorded response", () => { const recorded = timedCaptureEvidence([ diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts index 1fa4eb3cf7..63c7254bc6 100644 --- a/src/application/environment/environmentCompare.ts +++ b/src/application/environment/environmentCompare.ts @@ -81,6 +81,10 @@ export interface CompareStep { readonly equal_ignoring_order: boolean; readonly categories: DifferenceCategory[]; readonly differences: Difference[]; + /** All differences computed for this step, before the returned list is capped. */ + readonly differences_total: number; + readonly differences_omitted: number; + readonly differences_truncated: boolean; readonly ignored_query: string[]; } @@ -287,6 +291,8 @@ const stepOf = ( outcome: { env_status: number | null; differences: Difference[] }, ): CompareStep => { const categories = [...new Set(outcome.differences.map((d) => d.category))]; + const differences = outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT); + const omitted = outcome.differences.length - differences.length; return { ...base, tool: call?.tool.name ?? null, @@ -296,7 +302,10 @@ const stepOf = ( (difference) => difference.category === "order", ), categories, - differences: outcome.differences.slice(0, STEP_DIFFERENCE_LIMIT), + differences, + differences_total: outcome.differences.length, + differences_omitted: omitted, + differences_truncated: omitted > 0, ignored_query: [...(call?.ignoredQuery ?? [])], }; }; diff --git a/src/application/investigation/InspectEvidenceBundle.ts b/src/application/investigation/InspectEvidenceBundle.ts index 8831becc2f..1a186bb02c 100644 --- a/src/application/investigation/InspectEvidenceBundle.ts +++ b/src/application/investigation/InspectEvidenceBundle.ts @@ -2,24 +2,41 @@ import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../../domain/result.js"; import { + inspectEvidenceLinks, selectEvidenceRecords, summarizeEvidenceBundle, type EvidenceBundleSummary, + type EvidenceLinkInspection, type EvidenceRecordFilters, type EvidenceRecordSelection, } from "../../domain/evidenceRecordSummary.js"; -/** Select complete bundle delivery, discovery metadata, or a page of complete selected records. */ +type InspectionDetail = "complete" | "summary" | "records" | "links"; + +/** Fields owned by particular deliveries, refused elsewhere rather than ignored. */ +const DELIVERY_FIELDS = [ + ["offset", ["records", "links"]], + ["limit", ["records", "links"]], + ["traversal", ["links"]], + ["link_status", ["links"]], +] as const; + +/** Select complete delivery, discovery metadata, complete records, or link membership. */ export const inspectEvidenceBundle = ( bundle: EvidenceBundle, options: { - readonly detail: "complete" | "summary" | "records"; + readonly detail: InspectionDetail; readonly filters?: EvidenceRecordFilters | undefined; readonly offset?: number | undefined; readonly limit?: number | undefined; + readonly traversal?: "direct" | "retained-closure" | undefined; + readonly link_status?: "missing" | "retained" | "all" | undefined; }, ): Result< - EvidenceBundle | EvidenceBundleSummary | EvidenceRecordSelection, + | EvidenceBundle + | EvidenceBundleSummary + | EvidenceRecordSelection + | EvidenceLinkInspection, AnalysisInputError > => { if (options.detail === "complete" && options.filters !== undefined) @@ -29,29 +46,34 @@ export const inspectEvidenceBundle = ( path: ["filters"], reason: "invalid_value", message: - "Record filters require detail: summary or records; complete returns the canonical bundle.", - expected: "detail: summary or records", + "Record filters require detail: summary, records or links; complete returns the canonical bundle.", + expected: "detail: summary, records or links", }, ]), ); - if (options.detail !== "records") { - const paging = (["offset", "limit"] as const).filter( - (field) => options[field] !== undefined, - ); - if (paging.length > 0) - return err( - new AnalysisInputError( - "get_evidence_bundle", - undefined, - paging.map((field) => ({ + const misplaced = DELIVERY_FIELDS.filter( + ([field, details]) => + options[field] !== undefined && + !(details as readonly InspectionDetail[]).includes(options.detail), + ); + if (misplaced.length > 0) + return err( + new AnalysisInputError( + "get_evidence_bundle", + undefined, + misplaced.map(([field, details]) => { + const expected = details + .map((detail) => `detail: ${detail}`) + .join(" or "); + return { path: [field], reason: "invalid_value" as const, - message: `${field} pages complete records and requires detail: records.`, - expected: "detail: records", - })), - ), - ); - } + message: `${field} requires ${expected}.`, + expected, + }; + }), + ), + ); if (options.detail === "records") return ok( selectEvidenceRecords(bundle, options.filters, { @@ -59,6 +81,15 @@ export const inspectEvidenceBundle = ( limit: options.limit, }), ); + if (options.detail === "links") + return ok( + inspectEvidenceLinks(bundle, options.filters, { + traversal: options.traversal, + linkStatus: options.link_status, + offset: options.offset, + limit: options.limit, + }), + ); return ok( options.detail === "summary" ? summarizeEvidenceBundle(bundle, options.filters) diff --git a/src/application/investigation/recordEvidenceSources.ts b/src/application/investigation/recordEvidenceSources.ts index fcd029cc09..e3c34ba1b0 100644 --- a/src/application/investigation/recordEvidenceSources.ts +++ b/src/application/investigation/recordEvidenceSources.ts @@ -5,7 +5,10 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js"; /** Source facts remain available even when a completed observation is cancelled. */ -export type AnalysisResultWithSources = Result & { +export type AnalysisResultWithSources = Result< + Value, + Failure +> & { readonly sourceEvidence?: readonly Evidence[]; }; diff --git a/src/application/native/NativeFunctionMatchingService.ts b/src/application/native/NativeFunctionMatchingService.ts index 02e75920cf..a131f5bf0b 100644 --- a/src/application/native/NativeFunctionMatchingService.ts +++ b/src/application/native/NativeFunctionMatchingService.ts @@ -10,7 +10,11 @@ import { } from "../../contracts/native/functionMatchingContracts.js"; import { createEvidence, type Evidence } from "../../domain/evidence.js"; import { readFunctionInventory } from "../../domain/native/functionInventory.js"; -import { nativeFunctionMatchingSchema } from "../../domain/native/functionMatching.js"; +import { + nativeFunctionMatchingSchema, + nativeFunctionByteSourceGuidance, + NATIVE_FUNCTION_MATCHING_PREDICATE, +} from "../../domain/native/functionMatching.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js"; import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js"; @@ -20,7 +24,6 @@ import { resolveNativeMatchingSources } from "./NativeMatchingSources.js"; import type { ExecutionOptions } from "../AnalysisProvider.js"; import type { NativeMatchingAllowance } from "../../config/nativeMatchingAllowance.js"; import { FUNCTION_COMPARISON_PROVIDER } from "../../domain/comparisonEvidenceIdentity.js"; -import { NATIVE_FUNCTION_MATCHING_PREDICATE } from "../../domain/native/functionMatching.js"; const OPERATION = "match_native_functions"; @@ -92,6 +95,38 @@ const matchWithAdmission = async ( rightFunctions, { identicalBytesComparable: compatible, checkpoint }, ); + const byteSources: { + side: "left" | "right"; + evidence_id: string; + limitations: string[]; + }[] = []; + for (const [side, functions, observations] of [ + ["left", leftFunctions, leftObservations], + ["right", rightFunctions, rightObservations], + ] as const) { + const used = new Set(); + for (const item of functions) + for (const id of item.member.evidence_links) { + if (!used.has(id)) await checkpoint(128 + id.length * 4); + used.add(id); + } + for (const observation of observations) { + if ( + observation.operation !== "read_bytes" || + !used.has(observation.evidence_id) + ) + continue; + used.delete(observation.evidence_id); + await checkpoint(512 + observation.evidence_id.length * 8); + for (const limitation of observation.limitations) + await checkpoint(128 + limitation.length * 8); + byteSources.push({ + side, + evidence_id: observation.evidence_id, + limitations: [...observation.limitations], + }); + } + } const limitations = [ ...new Set([ ...left.limitations.map((x) => `Left: ${x}`), @@ -99,6 +134,7 @@ const matchWithAdmission = async ( "Coverage is restricted to the supplied provider-observed inventories; missing/truncated inventory facts never prove absence from either binary.", "Only unique exact symbols and bytes are paired. Compatible observed CFG/call/string fingerprints propose unresolved candidates, never identity or equivalence. Missing structural coverage stays unknown.", "Name or byte correspondence is not behavioral equivalence; use analyze_function for selected pairs, then compare_functions. Existing dossier pairs include directly usable retained-reference inputs.", + ...(byteSources.length === 0 ? [] : [nativeFunctionByteSourceGuidance]), ...(!compatible ? [ "Identical-byte matching unavailable: input architectures are missing or differ.", @@ -139,6 +175,8 @@ const matchWithAdmission = async ( ).length, }, summary, + byte_sources_count: byteSources.length, + byte_sources: byteSources, matches, limitations, }); diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index 1bd9fb1c7f..d2b1e930bc 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -1,6 +1,10 @@ import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { describe, expect, it } from "vitest"; -import { traceNativeValues } from "./NativeValueTrace.js"; +import { + traceNativeValues as traceNativeValuesCore, + UNRECORDED_NATIVE_VALUE_FLOW, +} from "./NativeValueTrace.js"; +import { executeComposedWorkflow } from "../WorkflowEvidence.js"; import { createAnalysisExecution, type AnalysisOperationPort, @@ -26,6 +30,27 @@ import { valueTraceProvider as provider, valueTraceSubject as subject, } from "./NativeValueTrace.fixture.js"; + +const traceNativeValues: typeof traceNativeValuesCore = async ( + source, + parameters, + signal, +) => { + const result = await executeComposedWorkflow(source, (observed) => + traceNativeValuesCore(observed, parameters, signal), + ); + return result.ok ? ok(result.value.result) : result; +}; + +it("does not return a source ID without an Evidence owner", async () => { + const result = await traceNativeValuesCore(valueTraceDiamond().port, { + procedure: "0x1000", + }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + const analysis: AnalysisOperationPort = { execute: async (operation, parameters) => { if (operation === "resolve_native_call_targets") @@ -168,12 +193,47 @@ describe("native value trace seed admission", () => { expect.objectContaining({ address: base.procedure.address }), ], unknowns: [ - expect.objectContaining({ - reason: "Provider has no native def-use model", - }), + { + procedure: base.procedure.address, + address: null, + reason: UNRECORDED_NATIVE_VALUE_FLOW, + }, ], }); }); + + it("preserves an explicit unavailable reason for that procedure", async () => { + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const providerReason = "selected procedure has no p-code model"; + const result = await traceNativeValues( + { + execute: async () => + ok( + createAnalysisExecution( + { + ...base, + native_value_flow: { + available: false, + reason: providerReason, + limitations: [], + }, + }, + provider, + { subject }, + ), + ), + }, + { procedure: base.procedure.address }, + ); + if (!result.ok) throw result.error; + expect(nativeValueTraceSchema.parse(result.value).unknowns).toEqual([ + { + procedure: base.procedure.address, + address: null, + reason: providerReason, + }, + ]); + }); }); describe("native value parameter-use node membership", () => { diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index de1e2efb68..bb2c03d9ac 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -7,6 +7,7 @@ import { import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js"; import { AnalysisCancelledError, + AnalysisCapabilityUnavailableError, AnalysisOutputError, } from "../../domain/analysisErrorCore.js"; import { err, ok } from "../../domain/result.js"; @@ -27,6 +28,10 @@ import { createValueTraceBudget } from "./NativeValueTraceBudget.js"; export const CLEANUP_STOPPED_VALUE_TRACE = "Trace stopped because the provider did not confirm cleanup of resources it owns"; +/** Fallback when a dossier has no native value-flow facet for this procedure. */ +export const UNRECORDED_NATIVE_VALUE_FLOW = + "Native def-use was not recorded for this procedure."; + /** Compose bounded high-p-code def-use graphs across exactly referenced call destinations. */ export const traceNativeValues = async ( analysis: AnalysisOperationPort, @@ -116,6 +121,7 @@ export const traceNativeValues = async ( { ...(signal === undefined ? {} : { signal }), ...(next.depth > 0 ? { selectorOrigin: "provider" as const } : {}), + retainEvidence: true, }, ); decompilations++; @@ -129,12 +135,13 @@ export const traceNativeValues = async ( } continue; } - const evidence = createAnalysisExecutionEvidence( - "analyze_function", - { procedure: next.procedure }, - execution.value, - "observed", - ); + const partialEvidence = () => + createAnalysisExecutionEvidence( + "analyze_function", + { procedure: next.procedure }, + execution.value, + "observed", + ); const parsed = functionDossierSchema.safeParse(execution.value.result); if (!parsed.success || execution.value.subject == null) return err( @@ -142,7 +149,7 @@ export const traceNativeValues = async ( "trace_native_values", "Function dossier lacks validated facts or artifact identity", parsed.success ? undefined : { cause: parsed.error }, - ).retainPartialObservation(evidence), + ).retainPartialObservation(partialEvidence()), ); const dossier = parsed.data; const address = dossier.procedure.address; @@ -155,9 +162,17 @@ export const traceNativeValues = async ( new AnalysisOutputError( "trace_native_values", "Cross-function target digest mismatch", - ).retainPartialObservation(evidence), + ).retainPartialObservation(partialEvidence()), + ); + const evidenceId = execution.value.evidenceId; + if (evidenceId === undefined) + return err( + new AnalysisCapabilityUnavailableError( + "rea", + "trace_native_values", + "The function observation has no Evidence retention owner.", + ).retainPartialObservation(partialEvidence()), ); - const evidenceId = evidence.evidence_id; procedures.push({ address, depth: next.depth, @@ -170,7 +185,7 @@ export const traceNativeValues = async ( unknowns.push({ procedure: address, address: null, - reason: flow?.reason ?? "Provider has no native def-use model", + reason: flow?.reason ?? UNRECORDED_NATIVE_VALUE_FLOW, }); continue; } diff --git a/src/application/native/NativeValueTraceStops.test.ts b/src/application/native/NativeValueTraceStops.test.ts index fae841232e..40a1568d63 100644 --- a/src/application/native/NativeValueTraceStops.test.ts +++ b/src/application/native/NativeValueTraceStops.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; +import { ok } from "../../domain/result.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -34,6 +35,21 @@ describe("native value trace child stops", () => { 0, VALUE_TRACE_DIAMOND_READS.indexOf(read) + 1, ); + // The trace cites each completed dossier. These cases cover stop behavior, + // so the fixture only has to satisfy that precondition. + const withSourceEvidence = ( + port: AnalysisOperationPort, + ): AnalysisOperationPort => ({ + execute: async (operation, parameters, options) => { + const execution = await port.execute(operation, parameters, options); + if (!execution.ok || execution.value.evidenceId !== undefined) + return execution; + return ok({ + ...execution.value, + evidenceId: `ev_${"ab".repeat(32)}`, + }); + }, + }); it.each(childReads)( "returns a cancelled %s read without a partial trace", @@ -43,7 +59,7 @@ describe("native value trace child stops", () => { [read]: cancelled, }); - const result = await traceNativeValues(port, { + const result = await traceNativeValues(withSourceEvidence(port), { procedure: "0x1000", max_depth: 1, }); @@ -86,7 +102,7 @@ describe("native value trace child stops", () => { }; const result = await traceNativeValues( - aborting, + withSourceEvidence(aborting), { procedure: "0x1000", max_depth: 1 }, controller.signal, ); @@ -114,7 +130,7 @@ describe("native value trace child stops", () => { "resolve_native_call_targets:0x1008": failure, }); - const result = await traceNativeValues(port, { + const result = await traceNativeValues(withSourceEvidence(port), { procedure: "0x1000", max_depth: 1, }); diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index e4117b31a8..02fe3b53c3 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -549,7 +549,7 @@ export class ReverseEngineeringService { ? "REA_OBJDUMP_COMMAND" : "REA_RIZIN_COMMAND"; earlyFailure = new AnalysisCapabilityUnavailableError( - provider, + profile.identity.id, operation, `Unable to run configured command (${String(code ?? "unknown error")})`, { @@ -708,10 +708,10 @@ export class ReverseEngineeringService { const limitations = [ ...(absolutePath === undefined ? [ - "The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.", + "The objdump implementation and version are unknown. The captured output is the selected command's response and does not establish support for an artifact.", ] : [ - "The executable version and supported formats are reported as unknown because no reliable version probe was performed.", + "The executable implementation, version and supported formats are unknown because no reliable identity probe was performed.", ]), "A non-zero process exit is preserved as an observed command result.", ...(operation === "inspect_with_objdump" && @@ -759,9 +759,7 @@ export class ReverseEngineeringService { `Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`, ]), ...(absolutePath === undefined - ? [ - "This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.", - ] + ? [] : [ "When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.", ]), diff --git a/src/application/wasm/WasmInterfaceComparisonService.test.ts b/src/application/wasm/WasmInterfaceComparisonService.test.ts index 14cd133185..138f9de6ed 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.test.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.test.ts @@ -168,6 +168,40 @@ it("requires the producer's file subject format even for authentic Evidence", as ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); }); +it.each(["left", "right"] as const)( + "names the %s argument whose Evidence is rejected", + async (side) => { + const tampered = JSON.parse(JSON.stringify(evidence(facts("f")))); + tampered.normalized_result.interface.entries[0].name = "tampered"; + const report = wasmArtifactFixture(); + const otherSelection = createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + { id: "fixture", name: "typed fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: { path: "/artifacts/other.wasm" }, + result: report, + confidence: "observed", + }, + ); + for (const rejected of [tampered, otherSelection]) + expect( + await compareWasmInterfaces({ + left: side === "left" ? rejected : evidence(facts("f")), + right: side === "right" ? rejected : evidence(facts("f")), + scope: "both", + }), + ).toMatchObject({ + ok: false, + error: { issues: [{ path: [side], reason: "invalid_value" }] }, + }); + }, +); + it("resolves exact retained references and permits cancellation between comparison batches", async () => { const left = evidence(facts("f")); const right = evidence(facts("f", "i64")); diff --git a/src/application/wasm/WasmInterfaceComparisonService.ts b/src/application/wasm/WasmInterfaceComparisonService.ts index a71b6e5491..d7689cdbe1 100644 --- a/src/application/wasm/WasmInterfaceComparisonService.ts +++ b/src/application/wasm/WasmInterfaceComparisonService.ts @@ -40,16 +40,19 @@ import { const OPERATION = "compare_wasm_interfaces"; const parseInspection = ( raw: unknown, + side: "left" | "right", ): Result<{ evidence: Evidence; result: WasmArtifact }, AnalysisInputError> => { const authenticated = safeParseEvidence(raw); - if (!authenticated.ok) return inspectionInputFailure(authenticated.error); + if (!authenticated.ok) + return inspectionInputFailure(authenticated.error, side); const evidence = authenticated.value; if (evidence.operation !== "inspect_wasm_artifact") return inspectionInputFailure( new TypeError("Expected complete inspect_wasm_artifact Evidence"), + side, ); const parsed = wasmArtifactSchema.safeParse(evidence.normalized_result); - if (!parsed.success) return inspectionInputFailure(parsed.error); + if (!parsed.success) return inspectionInputFailure(parsed.error, side); const result = parsed.data; if ( !wasmArtifactMatchesSubject( @@ -67,16 +70,19 @@ const parseInspection = ( new TypeError( "WASM Evidence subject/selection does not match normalized artifact identity", ), + side, ); return ok({ evidence, result }); }; +/** Attribute a rejected side to the input argument that supplied it. */ const inspectionInputFailure = ( cause: Error, + side: "left" | "right", ): Result => err( new AnalysisInputError(OPERATION, { cause }, [ - { path: [], reason: "invalid_value", message: cause.message }, + { path: [side], reason: "invalid_value", message: cause.message }, ]), ); @@ -107,9 +113,9 @@ export const compareWasmInterfaces = async ( try { await setImmediate(); assertActive(); - const parsedLeft = parseInspection(resolved.value.input.left); + const parsedLeft = parseInspection(resolved.value.input.left, "left"); if (!parsedLeft.ok) return parsedLeft; - const parsedRight = parseInspection(resolved.value.input.right); + const parsedRight = parseInspection(resolved.value.input.right, "right"); if (!parsedRight.ok) return parsedRight; const left = parsedLeft.value; const right = parsedRight.value; diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts index b4af7af826..a1eddf2eff 100644 --- a/src/artifacts/ArtifactProvider.ts +++ b/src/artifacts/ArtifactProvider.ts @@ -270,8 +270,12 @@ class ArtifactClient implements AnalysisClient { ); } if (operation === "inspect_keyed_archive") { + // A bundle target reads bundle-relative archives whatever its + // executable admitted as; only a plist opened on its own is standalone. const standalone = - this.target.kind === "artifact" && this.target.format === "plist"; + this.target.bundleInfoPlist === undefined && + this.target.kind === "artifact" && + this.target.format === "plist"; const selectedPath = artifactTargetSelectedPath(this.target); const result = standalone ? await inspectStandaloneKeyedArchive({ diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index e2fc1b6219..ae1621a577 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -26,6 +26,8 @@ import type { export interface ArtifactReaderFailureOptions extends ErrorOptions { /** Recovery guidance established by the producing adapter, separate from diagnostics. */ readonly userMessage?: string | undefined; + /** Regular-file path observed behind a rejected selected symlink; retry still revalidates it. */ + readonly resolvedPath?: string | undefined; readonly cancellation?: { readonly reason: unknown } | undefined; readonly cleanup?: AnalysisCleanupObservation | undefined; /** Exact authority for all reported cleanup; undefined leaves ownership unknown. */ @@ -197,6 +199,7 @@ export interface ArtifactReader { | "ines" | "godot-pck" | "unityfs" + | "wxapkg" | "uimage" | "pyinstaller" | "dotnet-bundle" @@ -230,6 +233,7 @@ export interface ArtifactReader { /** Typed adapter failure translated at provider boundary. */ export class ArtifactReaderFailure extends Error { readonly userMessage: string | undefined; + readonly resolvedPath: string | undefined; readonly cancellation: { readonly reason: unknown } | undefined; readonly cleanup: AnalysisCleanupObservation | undefined; readonly cleanupLeases: readonly AnalysisCleanupLease[] | undefined; @@ -255,6 +259,7 @@ export class ArtifactReaderFailure extends Error { super(message, options); this.name = "ArtifactReaderFailure"; this.userMessage = options?.userMessage; + this.resolvedPath = options?.resolvedPath; this.cancellation = reason === "cancelled" ? (options?.cancellation ?? { reason: options?.cause }) @@ -306,6 +311,7 @@ export class ArtifactReaderFailure extends Error { { cause: primary, userMessage: primary.userMessage, + resolvedPath: primary.resolvedPath, cancellation: primary.cancellation, cleanup: combineCleanup(primary.cleanup, cleanup), cleanupLeases: diff --git a/src/artifacts/ArtifactSourceResolution.test.ts b/src/artifacts/ArtifactSourceResolution.test.ts index 745705b4aa..3a87e57f0b 100644 --- a/src/artifacts/ArtifactSourceResolution.test.ts +++ b/src/artifacts/ArtifactSourceResolution.test.ts @@ -8,6 +8,9 @@ import { assertArtifactFileSelectedSource } from "./ArtifactFileSource.js"; import { assertArtifactSelectedSource } from "./ArtifactSelectedSource.js"; import { readStableArtifact } from "./readStableArtifact.js"; import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js"; +import { createDesktopApplicationService } from "../composition/desktop.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { desktopApplicationSchema } from "../domain/desktopApplication.js"; it.skipIf(process.platform === "win32")( "admits a caller-selected directory symlink without changing its target identity", @@ -46,6 +49,54 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32")( + "rejects desktop inspection when the selected spelling traverses a regular file", + async () => { + const root = await createTestTempDirectory( + "rea-desktop-enotdir-intermediate-", + ); + const app = join(root, "Demo.app"); + await mkdir(app); + await writeFile( + join(app, "Info.plist"), + ` + +CFBundleIdentifiercom.example.demo`, + ); + await writeFile(join(app, "marker.txt"), "marker\n"); + await writeFile(join(root, "regular"), "regular file\n"); + const service = createDesktopApplicationService(); + try { + const invalid = await service.inspect({ + path: `${root}/regular/../Demo.app`, + max_entries: 32, + max_probe_bytes: 4096, + }); + expect(invalid.ok).toBe(false); + if (invalid.ok) throw new Error("expected ENOTDIR refusal"); + expect(projectAnalysisError(invalid.error)).toMatchObject({ + code: "target_unavailable", + category: "unavailable", + }); + const valid = await service.inspect({ + path: app, + max_entries: 32, + max_probe_bytes: 4096, + }); + expect(valid.ok).toBe(true); + if (!valid.ok) throw valid.error; + expect( + desktopApplicationSchema + .parse(valid.value.normalized_result) + .files.map((file) => file.path) + .sort(), + ).toEqual(["Info.plist", "marker.txt"]); + } finally { + await service.close(); + } + }, +); + it.skipIf(process.platform === "win32")( "reports a replaced ancestor symlink loop as a changed admitted source", async () => { @@ -105,3 +156,35 @@ it.each(["ENOENT", "ENOTDIR", "ELOOP", "ENAMETOOLONG", "EACCES", "EIO"])( }); }, ); + +it.skipIf(process.platform === "win32")( + "keeps a rejected symlink without a hint when its resolved target lookup fails", + async () => { + const root = await createTestTempDirectory("rea-stable-symlink-lookup-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "carrier"); + await symlink(target, selected); + const selectedStats = await lstat(selected, { bigint: true }); + const failure = Object.assign(new Error("target lookup failed"), { + code: "EACCES", + }); + + await expect( + readStableArtifact(selected, 1024, undefined, { + lstat: async (path) => { + if (path === selected) return selectedStats; + throw failure; + }, + realpath: async () => target, + open: async () => { + throw new Error("A rejected symlink must not be opened"); + }, + }), + ).rejects.toMatchObject({ + reason: "path", + resolvedPath: undefined, + message: `Expected a regular file without a symlink: ${selected}`, + }); + }, +); diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 92d7191d3a..27542d1f23 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -78,7 +78,21 @@ export class AsarArtifactReader implements ArtifactReader { jsonBytes: admitted.jsonBytes, }; assertAsarReadActive(signal); - return readAsarListing(admitted.header, signal); + try { + return await readAsarListing(admitted.header, signal); + } catch (cause: unknown) { + // Listing names members relative to this archive; keep which archive. + if ( + cause instanceof ArtifactReaderFailure && + (cause.reason === "format" || cause.reason === "path") + ) + throw new ArtifactReaderFailure( + cause.reason, + `${cause.message} in ${this.path}`, + { cause }, + ); + throw cause; + } }, signal); } catch (cause: unknown) { throw asarFailure(this.path, "inventory", cause); @@ -202,6 +216,18 @@ export class AsarArtifactReader implements ArtifactReader { offset === undefined || headerSize === undefined ? undefined : 8 + headerSize + offset; + if (facts.size === null) + throw new ArtifactEntryContentUnavailable( + `ASAR entry declares no readable size: ${producedEntry.path}`, + "ASAR packed entry bytes were not read because its declared size cannot bound them; no content hash or child artifact was produced.", + undefined, + artifactIntegrityDetails( + producedEntry.path, + producedEntry.declaredIntegrity, + null, + false, + ), + ); if ( archiveSize === undefined || start === undefined || diff --git a/src/artifacts/AsarListing.ts b/src/artifacts/AsarListing.ts index e8bf49c0bb..f4744ec1c7 100644 --- a/src/artifacts/AsarListing.ts +++ b/src/artifacts/AsarListing.ts @@ -1,6 +1,7 @@ import { setImmediate } from "node:timers/promises"; +import { z } from "zod"; -import { asarNodeSchema } from "./AsarNode.js"; +import { asarDeclaredSizeSchema, asarNodeSchema } from "./AsarNode.js"; import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js"; import { assertAsarReadActive } from "./AsarFailure.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; @@ -9,6 +10,68 @@ import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const MAX_LISTING_BYTES = 64 * 1024 * 1024; const BLOCK_INTEGRITY_LIMITATION = "ASAR block integrity declarations were not validated. Whole-file digest comparison does not establish block integrity or Electron runtime acceptance."; +/** Source text quoted in listing diagnostics; longer values keep their length. */ +const MAX_QUOTED_SOURCE_UNITS = 256; +const quoteSource = (value: string): string => + value.length <= MAX_QUOTED_SOURCE_UNITS + ? JSON.stringify(value) + : `${JSON.stringify(value.slice(0, MAX_QUOTED_SOURCE_UNITS))}... (${String(value.length)} UTF-16 code units)`; +// Listing keeps a member whose size cannot bound its content, as it keeps an +// unreadable offset; only reading that member fails. +const asarListingNodeSchema = asarNodeSchema.extend({ + size: z.unknown().optional(), +}); + +const malformedMetadata = (subject: string, error: z.ZodError) => { + const fields = [ + ...new Set( + error.issues.map((issue) => + issue.path.length === 0 ? "entry" : issue.path.join("."), + ), + ), + ].join(", "); + return new ArtifactReaderFailure( + "format", + `${subject} metadata is malformed (${fields})`, + { cause: error }, + ); +}; + +/** Interpret one declared size once; an invalid one keeps a source fact, not a number. */ +const readDeclaredSize = ( + value: unknown, +): { readonly size: number | null; readonly limitation?: string } => { + if (value === undefined) return { size: null }; + const parsed = asarDeclaredSizeSchema.safeParse(value); + if (parsed.success) return { size: parsed.data }; + // JSON.parse has already rounded a number beyond the safe-integer range or + // turned it into Infinity, so its source spelling is unknown here. + if ( + typeof value === "number" && + !(Number.isFinite(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER) + ) + return { + size: null, + limitation: + "ASAR entry declares a numeric size outside the safe-integer range; its exact source value was not retained.", + }; + const declared = + typeof value === "number" + ? `size ${String(value)}` + : typeof value === "string" + ? `size ${quoteSource(value)}` + : value === null + ? "a null size" + : Array.isArray(value) + ? "an array size" + : typeof value === "object" + ? "an object size" + : `a ${typeof value} size`; + return { + size: null, + limitation: `ASAR entry declares ${declared}, which is not a non-negative safe integer.`, + }; +}; type AsarListedFacts = Pick< ArtifactEntry, @@ -19,7 +82,8 @@ export type AsarListedEntry = AsarListedFacts & | { readonly kind: "directory" | "symlink"; readonly size: number | null } | { readonly kind: "file"; - readonly size: number; + /** Null when the declared size cannot bound the member's content. */ + readonly size: number | null; readonly packedOffset: number | undefined; } ); @@ -41,7 +105,10 @@ export const readAsarListing = async ( header: unknown, signal?: AbortSignal, ): Promise => { - const root = asarNodeSchema.parse(header); + const parsedRoot = asarListingNodeSchema.safeParse(header); + if (!parsedRoot.success) + throw malformedMetadata("ASAR root", parsedRoot.error); + const root = parsedRoot.data; if (root.files === undefined) throw new ArtifactReaderFailure("format", "ASAR root has no files table"); const entries: AsarListedEntry[] = []; @@ -81,7 +148,7 @@ export const readAsarListing = async ( ) throw new ArtifactReaderFailure( "path", - "ASAR header has an unsafe path component", + `ASAR header has an unsafe path component under ${frame.parent.length === 0 ? "the archive root" : quoteSource(frame.parent)}: ${quoteSource(name)}`, ); const units = frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1); @@ -89,7 +156,10 @@ export const readAsarListing = async ( // occurrence even when the engine can share a string prefix. admitRetainedBytes(units * 2 + 256); const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`; - const metadata = asarNodeSchema.parse(raw); + const parsed = asarListingNodeSchema.safeParse(raw); + if (!parsed.success) + throw malformedMetadata(`ASAR entry ${quoteSource(path)}`, parsed.error); + const metadata = parsed.data; if ( metadata.files === undefined && metadata.link === undefined && @@ -97,13 +167,21 @@ export const readAsarListing = async ( ) throw new ArtifactReaderFailure( "format", - "ASAR entry has no directory, link or file metadata", + `ASAR entry ${quoteSource(path)} has no directory, link or file metadata`, ); const declaration = metadata.integrity; const supportedIntegrity = declaration?.algorithm === "SHA256" && /^[a-f0-9]{64}$/u.test(declaration.hash); const limitations: string[] = []; + const { size, limitation: sizeLimitation } = readDeclaredSize( + metadata.size, + ); + if (sizeLimitation !== undefined) { + // Quoted source text is already bounded; charge the retained copy. + admitRetainedBytes(sizeLimitation.length * 2 + 64); + limitations.push(sizeLimitation); + } if ( declaration !== undefined && (Object.hasOwn(declaration, "blockSize") || @@ -141,13 +219,13 @@ export const readAsarListing = async ( entries.push({ ...facts, kind: metadata.files !== undefined ? "directory" : "symlink", - size: metadata.size ?? null, + size, }); else if (metadata.size !== undefined) entries.push({ ...facts, kind: "file", - size: metadata.size, + size, packedOffset: parseArchiveOffset(metadata.offset), }); if (metadata.files !== undefined) diff --git a/src/artifacts/AsarNode.ts b/src/artifacts/AsarNode.ts index b47a22a12b..ee2ad4f589 100644 --- a/src/artifacts/AsarNode.ts +++ b/src/artifacts/AsarNode.ts @@ -1,5 +1,8 @@ import { z } from "zod"; +/** A declared ASAR member size that can bound a packed content range. */ +export const asarDeclaredSizeSchema = z.number().nonnegative().safe(); + /** Shared ASAR node declarations; consumers select only the fields they interpret. */ export const asarNodeSchema = z.looseObject({ files: z @@ -9,7 +12,7 @@ export const asarNodeSchema = z.looseObject({ ) .optional(), link: z.string().optional(), - size: z.number().nonnegative().safe().optional(), + size: asarDeclaredSizeSchema.optional(), offset: z.string().optional(), unpacked: z.boolean().optional(), executable: z.boolean().optional(), diff --git a/src/artifacts/DesktopApplicationReader.ts b/src/artifacts/DesktopApplicationReader.ts index 5f05bc03e8..7bf2efc7f9 100644 --- a/src/artifacts/DesktopApplicationReader.ts +++ b/src/artifacts/DesktopApplicationReader.ts @@ -1,5 +1,5 @@ import { desktopPathConventions } from "../domain/nativeConvention.js"; -import { isAbsolute } from "node:path"; +import { basename, isAbsolute } from "node:path"; import { DirectoryArtifactReader, DirectoryArtifactRootFailure, @@ -236,6 +236,7 @@ const scanDirectory = async ( const files: DesktopFile[] = []; const omissions: DesktopOmission[] = []; const unityWitnessRoots = new Set(); + const rootName = basename(root.canonical_path); const budget: DesktopReadBudget = { bytes: 0, maximum: input.max_probe_bytes, @@ -299,9 +300,12 @@ const scanDirectory = async ( entries++; abortIfNeeded(signal); const conventions = - entry.kind === "file" ? pathConventions(entry.path) : undefined; + entry.kind === "file" + ? pathConventions(entry.path, rootName) + : undefined; // Reserve the row and every repeated path before retaining them: each - // convention fact copies the path and its probes copy the local path. + // convention fact copies at most the path and its probes copy the + // local path. reserve( RETAINED_ROW_BYTES + retainedTextBytes(entry.path) + @@ -487,10 +491,19 @@ const fileRow = ( }; }; -/** Path-convention facts of one file; each repeats the path as its value. */ -const pathConventions = (path: string) => { - const conventions = desktopPathConventions(path); +/** Path-convention facts of one file; each value repeats at most the path. */ +const pathConventions = (path: string, rootName: string) => { + const conventions = desktopPathConventions(path, rootName); const facts: DesktopFact[] = []; + if ( + conventions.unityIl2cppBackupAssembly && + conventions.unityIl2cppBackupDirectory !== null + ) + facts.push({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: conventions.unityIl2cppBackupDirectory }, + }); if (conventions.electronArchive) facts.push({ kind: "electron-asar-convention", offset: null, value: path }); if (conventions.unrealContainer) diff --git a/src/artifacts/EspFlashImageArtifactReader.test.ts b/src/artifacts/EspFlashImageArtifactReader.test.ts index 8bb8f38208..896ec33cd0 100644 --- a/src/artifacts/EspFlashImageArtifactReader.test.ts +++ b/src/artifacts/EspFlashImageArtifactReader.test.ts @@ -376,6 +376,24 @@ describe("ESP flash layout reader identity", () => { } }); + it("opens a partition past the end as the empty range at the end of the file", async () => { + const cut = DUMP.subarray(0, 0x2_8000); + const reader = new EspFlashImageArtifactReader(await writeDump(cut)); + try { + const entry = (await list(reader)).find( + ({ path }) => path === "partitions/ota_1.bin", + ); + if (entry === undefined) throw new Error("Missing entry"); + expect(entry).toMatchObject({ byteOffset: cut.length, declaredSize: 0 }); + const chunks: Buffer[] = []; + for await (const chunk of await reader.open(entry)) + chunks.push(chunk as Buffer); + expect(Buffer.concat(chunks)).toHaveLength(0); + } finally { + await reader.close(); + } + }); + it("honors cancellation before admitting a file with the exact reason", async () => { const reader = new EspFlashImageArtifactReader(await writeDump(DUMP)); const controller = new AbortController(); diff --git a/src/artifacts/EspFlashImageArtifactReader.ts b/src/artifacts/EspFlashImageArtifactReader.ts index 2650a53fa7..7ab782f900 100644 --- a/src/artifacts/EspFlashImageArtifactReader.ts +++ b/src/artifacts/EspFlashImageArtifactReader.ts @@ -1,4 +1,3 @@ -import { Readable, Transform, pipeline } from "node:stream"; import type { FileHandle } from "node:fs/promises"; import type { EspFlashImageMetadata } from "../domain/artifactFormatMetadata.js"; @@ -21,18 +20,7 @@ import { import { offsetText } from "../domain/firmware/espImageStructures.js"; import { ESP_OTA_SELECT_ENTRY_BYTES } from "../domain/firmware/espOtaData.js"; import { isEspAppPartition } from "../domain/firmware/espPartitionTable.js"; -import { - ArtifactReaderFailure, - copyArtifactEntry, - sameArtifactEntry, - type ArtifactEntry, - type ArtifactReader, - type ArtifactReaderTraversal, - type ArtifactTraversalOptions, -} from "./ArtifactReader.js"; -import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; -import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { decodeEspFlashImage, firstUnerasedInRange, @@ -40,7 +28,11 @@ import { writtenExtents, type EspFlashReadContext, } from "./EspFlashRegionReads.js"; -import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +import { + PositionalArtifactReader, + type PositionalArtifactEntries, + type PositionalArtifactEntry, +} from "./PositionalArtifactReader.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; const LABEL = "ESP flash image"; @@ -62,18 +54,11 @@ type EspFlashImageFacts = NonNullable< * each entry is a positional read of the carrier, rechecked against its * identity. */ -export class EspFlashImageArtifactReader implements ArtifactReader { - readonly format = "esp-flash-image" as const; - readonly #scope = new ArtifactResourceScope(); - readonly #entries = new Map(); - readonly #streams = new Set(); - readonly #source: ArtifactFileSource; +export class EspFlashImageArtifactReader extends PositionalArtifactReader { + override readonly format = "esp-flash-image" as const; readonly #located: EspFlashLocation | undefined; readonly #selection: EspFlashSelection; - #prepared: Promise | undefined; #metadata: EspFlashImageMetadata | undefined; - #coverage: ArtifactReaderTraversal | undefined; - #closing = false; constructor( path: string, @@ -82,7 +67,7 @@ export class EspFlashImageArtifactReader implements ArtifactReader { ) { const admission = admitted !== undefined && "kind" in admitted ? admitted : undefined; - this.#source = new ArtifactFileSource( + super( path, LABEL, admission?.source ?? @@ -98,143 +83,10 @@ export class EspFlashImageArtifactReader implements ArtifactReader { : structuredClone(this.#metadata); } - async prepareContainer( - expectedSha256: string, + protected override async prepareEntries( signal?: AbortSignal, - ): Promise { - await this.#scope.run(async () => { - this.#assertOpen(signal); - await this.#source.bindSha256(expectedSha256, signal); - }); - } - - async *entries( - signal?: AbortSignal, - options?: ArtifactTraversalOptions, - ): AsyncIterable { - await this.#scope.run(async () => { - this.#assertOpen(signal); - this.#prepared ??= this.#prepare(signal); - await this.#prepared; - await this.#source.read(signal); - }); - this.#assertOpen(signal); - if (this.#coverage !== undefined) - options?.onCoverage?.({ - ...this.#coverage, - limitations: [...this.#coverage.limitations], - }); - for (const entry of this.#entries.values()) { - this.#assertOpen(signal); - yield copyArtifactEntry(entry); - } - this.#assertOpen(signal); - } - - async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { - return this.#scope.run(async () => { - this.#assertOpen(signal); - const stored = this.#entries.get(entry.adapterKey); - if ( - stored === undefined || - !sameArtifactEntry(entry, stored) || - stored.byteOffset === null || - stored.declaredSize === null - ) - throw new ArtifactReaderFailure( - "integrity", - `${LABEL} entry does not match its owning inventory`, - ); - const { handle } = await this.#source.read(signal); - this.#assertOpen(signal); - const source = - stored.declaredSize === 0 - ? Readable.from([]) - : readFileHandleChunks(handle, { - start: stored.byteOffset, - end: stored.byteOffset + stored.declaredSize - 1, - signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - `${LABEL} read cancelled`, - { cause: reason }, - ), - }); - const output = this.#entryStream(stored, signal); - // Retirement can destroy the stream before its consumer listens. - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Preserve active read/validation errors; after production, the caller - // still owns buffered bytes and cancellation must retire that result. - if (!producerComplete || output.destroyed) return; - output.destroy( - new ArtifactReaderFailure("cancelled", `${LABEL} read cancelled`, { - cause: signal?.reason, - }), - ); - }; - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); - }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - await Promise.all([ - new Promise((resolve) => { - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }); - }), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error(`${LABEL} stream ownership failed`, { cause }), - ), - ); - return output; - }); - } - - async close(): Promise { - this.#closing = true; - for (const stream of this.#streams) - stream.destroy( - new ArtifactReaderFailure("unavailable", `${LABEL} reader closed`), - ); - await this.#scope.close(); - await this.#source.close(); - this.#entries.clear(); - } - - provenance(): readonly [] { - return []; - } - - #assertOpen(signal?: AbortSignal): void { - assertArtifactReadActive(signal); - if (this.#closing) - throw new ArtifactReaderFailure( - "unavailable", - `${LABEL} reader is closed`, - ); - } - - async #prepare(signal?: AbortSignal): Promise { - const { handle, initial } = await this.#source.read(signal); + ): Promise { + const { handle, initial } = await this.verifyCarrier(signal); const fileSize = initial.size; const location = this.#located ?? (await this.#locate(handle, fileSize, signal)); @@ -251,11 +103,7 @@ export class EspFlashImageArtifactReader implements ArtifactReader { const images = await this.#decodeImages(context, plan); const written = await this.#readWritten(context, plan); // A changed carrier is an identity failure, not a different layout. - await this.#source.read(signal); - for (const region of plan.regions) { - const entry = regionEntry(region, written.get(region), fileSize); - this.#entries.set(entry.adapterKey, entry); - } + await this.verifyCarrier(signal); this.#metadata = layoutMetadata(location, plan, facts, images); const limitations = [ ...location.limitations, @@ -267,8 +115,15 @@ export class EspFlashImageArtifactReader implements ArtifactReader { ...facts.limitations, ...erasedSlotLimitations(images, written), ]; - if (facts.coverage === "unknown" || limitations.length > 0) - this.#coverage = { status: facts.coverage, limitations }; + return { + entries: plan.regions.map((region) => + regionEntry(region, written.get(region), fileSize), + ), + coverage: + facts.coverage === "unknown" || limitations.length > 0 + ? { status: facts.coverage, limitations } + : undefined, + }; } async #locate( @@ -308,12 +163,12 @@ export class EspFlashImageArtifactReader implements ArtifactReader { ); const gapFirstUnerased: (number | undefined)[] = []; for (const gap of plan.gaps) { - this.#assertOpen(context.signal); + this.assertOpen(context.signal); gapFirstUnerased.push( await firstUnerasedInRange( context, { start: gap.offset, length: gap.length }, - () => this.#assertOpen(context.signal), + () => this.assertOpen(context.signal), ), ); } @@ -332,14 +187,14 @@ export class EspFlashImageArtifactReader implements ArtifactReader { const held = plan.regions.filter( ({ partition, available }) => partition !== undefined && available > 0, ); - this.#assertOpen(context.signal); + this.assertOpen(context.signal); const extents = await writtenExtents( context, held.map(({ fileOffset, available }) => ({ start: fileOffset, length: available, })), - () => this.#assertOpen(context.signal), + () => this.assertOpen(context.signal), ); return new Map(held.map((region, index) => [region, extents[index]!])); } @@ -360,14 +215,14 @@ export class EspFlashImageArtifactReader implements ArtifactReader { region.role === "bootloader" || (region.partition !== undefined && isEspAppPartition(region.partition)); if (!image || region.available === 0) continue; - this.#assertOpen(context.signal); + this.assertOpen(context.signal); const range = `${String(region.fileOffset)}+${String(region.available)}`; let facts = decoded.get(range); if (facts === undefined) { facts = await decodeEspFlashImage( context, { start: region.fileOffset, length: region.available }, - () => this.#assertOpen(context.signal), + () => this.assertOpen(context.signal), ); decoded.set(range, facts); } @@ -376,35 +231,6 @@ export class EspFlashImageArtifactReader implements ArtifactReader { } return images; } - - #entryStream(entry: ArtifactEntry, signal?: AbortSignal): Transform { - let bytes = 0; - const check = async (): Promise => { - if (bytes !== entry.declaredSize) - throw new ArtifactReaderFailure( - "integrity", - `${LABEL} entry ended early: ${entry.path}`, - ); - await this.#source.read(signal); - }; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - bytes += chunk.length; - callback(null, chunk); - }, - flush(callback) { - void check().then( - () => callback(), - (cause: unknown) => - callback( - cause instanceof Error - ? cause - : new Error(`${LABEL} read failed`, { cause }), - ), - ); - }, - }); - } } /** App slots the bootloader finds empty that still hold written bytes. */ @@ -428,7 +254,7 @@ const regionEntry = ( region: EspFlashRegion, written: EspFlashWrittenExtent | undefined, fileSize: number, -): ArtifactEntry => ({ +): PositionalArtifactEntry => ({ path: region.path, ...(region.partition?.label === null || region.partition === undefined ? {} @@ -440,7 +266,8 @@ const regionEntry = ( // The table's encrypted flag says how a device stores the partition, not // that these bytes are ciphertext. encrypted: false, - // A partition past the end holds no bytes; its entry sits at the end. + // A partition past the end holds no bytes: its entry is the empty range + // at the end of the file, which opens as an empty stream without a read. byteOffset: Math.min(region.fileOffset, fileSize), declaredIntegrity: null, unpacked: false, diff --git a/src/artifacts/EspImageArtifactReader.ts b/src/artifacts/EspImageArtifactReader.ts index 39cfec7147..7e61aeafbb 100644 --- a/src/artifacts/EspImageArtifactReader.ts +++ b/src/artifacts/EspImageArtifactReader.ts @@ -1,10 +1,4 @@ -import { Readable, Transform, pipeline } from "node:stream"; - -import type { - EspImageMetadata, - EspImageSegmentMetadata, - EspImageSignatureBlockMetadata, -} from "../domain/artifactFormatMetadata.js"; +import type { EspImageMetadata } from "../domain/artifactFormatMetadata.js"; import { espChipForId } from "../domain/firmware/espChips.js"; import { espFlashDumpOrigin, @@ -22,49 +16,29 @@ import { ESP_SIGNATURE_BLOCK_BYTES, offsetText, } from "../domain/firmware/espImageStructures.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { readEspFlashRange } from "./EspFlashRegionReads.js"; import { - ArtifactReaderFailure, - copyArtifactEntry, - sameArtifactEntry, - type ArtifactEntry, - type ArtifactReader, - type ArtifactReaderTraversal, - type ArtifactTraversalOptions, -} from "./ArtifactReader.js"; -import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; -import { ArtifactFileSource } from "./ArtifactFileSource.js"; + PositionalArtifactReader, + type PositionalArtifactEntries, + type PositionalArtifactEntry, +} from "./PositionalArtifactReader.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; const LABEL = "ESP image"; -type EspImageEntry = ArtifactEntry & { - readonly byteOffset: number; - readonly declaredSize: number; - readonly formatMetadata: - | EspImageSegmentMetadata - | EspImageSignatureBlockMetadata; -}; /** * Inventory one ESP32-family image as its header-declared segments and * Secure Boot v2 signature blocks. Decoding reads the file once; each entry * is a positional read of the carrier, rechecked against its identity. */ -export class EspImageArtifactReader implements ArtifactReader { - readonly format = "esp-image" as const; - readonly #scope = new ArtifactResourceScope(); - readonly #entries = new Map(); - readonly #streams = new Set(); - readonly #source: ArtifactFileSource; - #prepared: Promise | undefined; +export class EspImageArtifactReader extends PositionalArtifactReader { + override readonly format = "esp-image" as const; #metadata: EspImageMetadata | undefined; - #coverage: ArtifactReaderTraversal | undefined; - #closing = false; constructor(path: string, admitted?: StableRegularFileDescriptor) { - this.#source = new ArtifactFileSource(path, LABEL, admitted); + super(path, LABEL, admitted); } formatMetadata(): EspImageMetadata | undefined { @@ -73,138 +47,11 @@ export class EspImageArtifactReader implements ArtifactReader { : structuredClone(this.#metadata); } - async prepareContainer( - expectedSha256: string, + protected override async prepareEntries( signal?: AbortSignal, - ): Promise { - await this.#scope.run(async () => { - this.#assertOpen(signal); - await this.#source.bindSha256(expectedSha256, signal); - }); - } - - async *entries( - signal?: AbortSignal, - options?: ArtifactTraversalOptions, - ): AsyncIterable { - await this.#scope.run(async () => { - this.#assertOpen(signal); - this.#prepared ??= this.#prepare(signal); - await this.#prepared; - await this.#source.read(signal); - }); - this.#assertOpen(signal); - if (this.#coverage !== undefined) - options?.onCoverage?.({ - ...this.#coverage, - limitations: [...this.#coverage.limitations], - }); - for (const entry of this.#entries.values()) { - this.#assertOpen(signal); - yield copyArtifactEntry(entry); - } - this.#assertOpen(signal); - } - - async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { - return this.#scope.run(async () => { - this.#assertOpen(signal); - const stored = this.#entries.get(entry.adapterKey); - if (stored === undefined || !sameArtifactEntry(entry, stored)) - throw new ArtifactReaderFailure( - "integrity", - `${LABEL} entry does not match its owning inventory`, - ); - const { handle } = await this.#source.read(signal); - this.#assertOpen(signal); - const source = - stored.declaredSize === 0 - ? Readable.from([]) - : readFileHandleChunks(handle, { - start: stored.byteOffset, - end: stored.byteOffset + stored.declaredSize - 1, - signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - `${LABEL} read cancelled`, - { cause: reason }, - ), - }); - const output = this.#entryStream(stored, signal); - // Retirement can destroy the stream before its consumer listens. - output.on("error", () => undefined); - this.#streams.add(output); - let producerComplete = false; - const onAbort = (): void => { - // Preserve active read/validation errors; after production, the caller - // still owns buffered bytes and cancellation must retire that result. - if (!producerComplete || output.destroyed) return; - output.destroy( - new ArtifactReaderFailure("cancelled", `${LABEL} read cancelled`, { - cause: signal?.reason, - }), - ); - }; - const retired = new Promise((resolve) => { - output.once("close", () => { - this.#streams.delete(output); - signal?.removeEventListener("abort", onAbort); - resolve(); - }); - }); - signal?.addEventListener("abort", onAbort, { once: true }); - if (signal?.aborted) onAbort(); - void this.#scope - .run(async () => { - await Promise.all([ - new Promise((resolve) => { - pipeline(source, output, () => { - producerComplete = true; - if (signal?.aborted) onAbort(); - resolve(); - }); - }), - retired, - ]); - }) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error(`${LABEL} stream ownership failed`, { cause }), - ), - ); - return output; - }); - } - - async close(): Promise { - this.#closing = true; - for (const stream of this.#streams) - stream.destroy( - new ArtifactReaderFailure("unavailable", `${LABEL} reader closed`), - ); - await this.#scope.close(); - await this.#source.close(); - this.#entries.clear(); - } - - provenance(): readonly [] { - return []; - } - - #assertOpen(signal?: AbortSignal): void { - assertArtifactReadActive(signal); - if (this.#closing) - throw new ArtifactReaderFailure( - "unavailable", - `${LABEL} reader is closed`, - ); - } - - async #prepare(signal?: AbortSignal): Promise { + ): Promise { const image = await this.#decode(signal); + const entries: PositionalArtifactEntry[] = []; for (const segment of image.segments) { const { index, data_len } = segment.metadata; const entry = { @@ -229,8 +76,8 @@ export class EspImageArtifactReader implements ArtifactReader { : [], adapterKey: `esp-image:segment:${String(index)}`, formatMetadata: { ...segment.metadata }, - } satisfies EspImageEntry; - this.#entries.set(entry.adapterKey, entry); + } satisfies PositionalArtifactEntry; + entries.push(entry); } for (const block of image.signatureBlocks) { const entry = { @@ -246,8 +93,8 @@ export class EspImageArtifactReader implements ArtifactReader { limitations: [], adapterKey: `esp-image:signature-block:${String(block.metadata.index)}`, formatMetadata: { ...block.metadata }, - } satisfies EspImageEntry; - this.#entries.set(entry.adapterKey, entry); + } satisfies PositionalArtifactEntry; + entries.push(entry); } const dump = await this.#flashDump(image, signal); this.#metadata = { @@ -264,8 +111,13 @@ export class EspImageArtifactReader implements ArtifactReader { ...image.limitations, ...(dump === undefined ? [] : [flashDumpLimitation(image, dump)]), ]; - if (image.coverage === "unknown" || limitations.length > 0) - this.#coverage = { status: image.coverage, limitations }; + return { + entries, + coverage: + image.coverage === "unknown" || limitations.length > 0 + ? { status: image.coverage, limitations } + : undefined, + }; } /** @@ -283,7 +135,8 @@ export class EspImageArtifactReader implements ArtifactReader { image.metadata.descriptor?.kind === "app" ) return undefined; - const { handle, initial } = await this.#source.read(signal); + this.assertOpen(signal); + const { handle, initial } = await this.verifyCarrier(signal); const context = { handle, fileSize: initial.size, signal }; return espFlashDumpOrigin( (position, length) => readEspFlashRange(context, position, length), @@ -295,7 +148,7 @@ export class EspImageArtifactReader implements ArtifactReader { /** Stream the carrier once, stopping when later bytes cannot matter. */ async #decode(signal?: AbortSignal): Promise { - const { handle, initial } = await this.#source.read(signal); + const { handle, initial } = await this.verifyCarrier(signal); const decoder = createEspImageDecoder(initial.size); if (initial.size > 0) for await (const chunk of readFileHandleChunks(handle, { @@ -306,45 +159,16 @@ export class EspImageArtifactReader implements ArtifactReader { cause: reason, }), }) as AsyncIterable) { - this.#assertOpen(signal); + this.assertOpen(signal); decoder.write(chunk); if (decoder.satisfied) break; } // A changed carrier is an identity failure, not a malformed image. - await this.#source.read(signal); + await this.verifyCarrier(signal); const image = decoder.finish(); if (!image.ok) throw decodeFailure(image.error); return image.value; } - - #entryStream(entry: EspImageEntry, signal?: AbortSignal): Transform { - let bytes = 0; - const check = async (): Promise => { - if (bytes !== entry.declaredSize) - throw new ArtifactReaderFailure( - "integrity", - `${LABEL} entry ended early: ${entry.path}`, - ); - await this.#source.read(signal); - }; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - bytes += chunk.length; - callback(null, chunk); - }, - flush(callback) { - void check().then( - () => callback(), - (cause: unknown) => - callback( - cause instanceof Error - ? cause - : new Error(`${LABEL} read failed`, { cause }), - ), - ); - }, - }); - } } const flashDumpLimitation = ( diff --git a/src/artifacts/GodotPckArtifactReader.boundaries.test.ts b/src/artifacts/GodotPckArtifactReader.boundaries.test.ts new file mode 100644 index 0000000000..331ed75cb5 --- /dev/null +++ b/src/artifacts/GodotPckArtifactReader.boundaries.test.ts @@ -0,0 +1,9 @@ +import { artifactReaderLifecycleCases } from "../../tests/fixtures/artifactReaderLifecycle.js"; +import { buildGodotPck } from "../../tests/fixtures/godotPck.js"; +import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js"; + +artifactReaderLifecycleCases({ + image: () => + buildGodotPck(4, [{ path: "res://member.txt", data: "member bytes" }]), + createReader: (path, admitted) => new GodotPckArtifactReader(path, admitted), +}); diff --git a/src/artifacts/GodotPckArtifactReader.test.ts b/src/artifacts/GodotPckArtifactReader.test.ts index b7a9a81a15..40587a34bb 100644 --- a/src/artifacts/GodotPckArtifactReader.test.ts +++ b/src/artifacts/GodotPckArtifactReader.test.ts @@ -12,7 +12,7 @@ import { join } from "node:path"; import { text } from "node:stream/consumers"; import { once } from "node:events"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { renameOpenParent } from "../../tests/fixtures/renameOpenParent.js"; @@ -420,6 +420,44 @@ describe("GodotPckArtifactReader member streams", () => { ); }); +describe("GodotPckArtifactReader carrier truncation", () => { + it("reports a member that ends after the carrier recheck as an integrity failure", async () => { + const path = await writePck( + buildGodotPck(4, [{ path: "member.bin", data: "member bytes" }]), + ); + const descriptor = await openRegularFile(path, { symlinks: "reject" }); + const reader = new GodotPckArtifactReader(path, descriptor); + try { + const [entry] = await list(reader); + if (entry === undefined) throw new Error("expected one entry"); + // open() rechecks the carrier's identity; the file then ends mid-member. + const read = descriptor.handle.read.bind(descriptor.handle); + let calls = 0; + const truncated = vi + .spyOn(descriptor.handle, "read") + .mockImplementation((...args: unknown[]) => { + calls += 1; + if (calls > 1) + return Promise.resolve({ bytesRead: 0, buffer: args[0] }); + args[2] = 3; + return Reflect.apply(read, descriptor.handle, args); + }); + try { + await expect(text(await reader.open(entry))).rejects.toMatchObject({ + reason: "integrity", + message: "Godot PCK entry ended early: member.bin", + }); + expect(calls).toBe(2); + } finally { + truncated.mockRestore(); + } + } finally { + await reader.close(); + await descriptor.handle.close(); + } + }); +}); + describe("Godot PCK logical-byte admission", () => { it.each([2, 3, 4] as const)( "rejects total repeated plaintext work before yielding format %s members", diff --git a/src/artifacts/GodotPckArtifactReader.ts b/src/artifacts/GodotPckArtifactReader.ts index 50d796c7b8..920066a48d 100644 --- a/src/artifacts/GodotPckArtifactReader.ts +++ b/src/artifacts/GodotPckArtifactReader.ts @@ -3,16 +3,7 @@ import { readGodotPckHeaderFacts } from "../domain/godotPckFormat.js"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; import type { FileHandle } from "node:fs/promises"; import { setImmediate } from "node:timers/promises"; -import { Readable, Transform, pipeline } from "node:stream"; -import { - ArtifactReaderFailure, - copyArtifactEntry, - sameArtifactEntry, - type ArtifactEntry, - type ArtifactReader, - type ArtifactTraversalOptions, -} from "./ArtifactReader.js"; -import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; import { locateGodotPck, @@ -21,9 +12,12 @@ import { } from "./GodotPckLocation.js"; import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js"; import { jsonValueByteLength } from "../domain/jsonValueByteLength.js"; -import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; -import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { + PositionalArtifactReader, + type PositionalArtifactEntries, + type PositionalArtifactEntry, +} from "./PositionalArtifactReader.js"; const PACK_DIR_ENCRYPTED = 1; const PACK_REL_FILEBASE = 2; @@ -35,16 +29,10 @@ const MAX_DIRECTORY_METADATA_BYTES = 64 * 1024 * 1024; const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); /** One descriptor, one validated directory, and bounded positional member reads. */ -export class GodotPckArtifactReader implements ArtifactReader { - readonly format = "godot-pck" as const; - readonly #scope = new ArtifactResourceScope(); - readonly #entries = new Map(); - readonly #streams = new Set(); - readonly #source: ArtifactFileSource; +export class GodotPckArtifactReader extends PositionalArtifactReader { + override readonly format = "godot-pck" as const; readonly #location: GodotPckLocation | undefined; - #prepared: Promise | undefined; #header: GodotPckMetadata | undefined; - #closing = false; constructor( path: string, @@ -52,7 +40,7 @@ export class GodotPckArtifactReader implements ArtifactReader { ) { const admission = admitted !== undefined && "kind" in admitted ? admitted : undefined; - this.#source = new ArtifactFileSource( + super( path, "Godot PCK", admitted !== undefined && "kind" in admitted ? admitted.source : admitted, @@ -64,131 +52,10 @@ export class GodotPckArtifactReader implements ArtifactReader { return this.#header === undefined ? undefined : { ...this.#header }; } - async prepareContainer( - expectedSha256: string, + protected override async prepareEntries( signal?: AbortSignal, - ): Promise { - await this.#scope.run(async () => { - this.#assertOpen(signal); - await this.#source.bindSha256(expectedSha256, signal); - }); - } - - async *entries( - signal?: AbortSignal, - options?: ArtifactTraversalOptions, - ): AsyncIterable { - await this.#scope.run(async () => { - this.#assertOpen(signal); - this.#prepared ??= this.#prepare(signal); - await this.#prepared; - await this.#source.read(signal); - }); - if (this.#header?.directory_status !== "available") { - options?.onCoverage?.({ - status: "unknown", - limitations: [ - this.#header?.directory_status === "encrypted" - ? "Godot PCK directory is encrypted; member identities and contents remain unknown." - : "Godot PCK sparse-bundle member bytes are external and were not traversed.", - ], - }); - return; - } - let index = 0; - for (const entry of this.#entries.values()) { - if (index++ % 256 === 0) await setImmediate(); - this.#assertOpen(signal); - yield copyArtifactEntry(entry); - } - } - - async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { - return this.#scope.run(async () => { - this.#assertOpen(signal); - const stored = this.#entries.get(entry.adapterKey); - if (stored === undefined || !sameArtifactEntry(entry, stored)) - throw new ArtifactReaderFailure( - "integrity", - "Godot PCK entry does not match its owning inventory", - ); - if (stored.encrypted) - throw new ArtifactReaderFailure( - "unavailable", - `Encrypted Godot PCK member is unsupported: ${stored.path}`, - ); - const { handle } = await this.#source.read(signal); - this.#assertOpen(signal); - if (stored.byteOffset === null || stored.declaredSize === null) - throw formatFailure("member has no physical byte range"); - const source = - stored.declaredSize === 0 - ? Readable.from([]) - : readFileHandleChunks(handle, { - start: stored.byteOffset, - end: stored.byteOffset + stored.declaredSize - 1, - signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - "Godot PCK read cancelled", - { cause: reason }, - ), - }); - const output = this.#memberStream(stored, signal); - // The returned stream still reports failures to its consumer. Also handle - // retirement before a caller has attached its own error listener. - output.on("error", () => undefined); - this.#streams.add(output); - // Writable completion can leave an unread member buffered on the readable - // side. The returned stream stays owned until that side closes too. - output.once("close", () => this.#streams.delete(output)); - void this.#scope - .run( - () => - new Promise((resolve) => { - pipeline(source, output, () => { - resolve(); - }); - }), - ) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("Godot PCK stream ownership failed", { cause }), - ), - ); - return output; - }); - } - - async close(): Promise { - this.#closing = true; - for (const stream of this.#streams) - stream.destroy( - new ArtifactReaderFailure("unavailable", "Godot PCK reader closed"), - ); - await this.#scope.close(); - await this.#source.close(); - this.#entries.clear(); - } - - provenance(): readonly [] { - return []; - } - - #assertOpen(signal?: AbortSignal): void { - assertArtifactReadActive(signal); - if (this.#closing) - throw new ArtifactReaderFailure( - "unavailable", - "Godot PCK reader is closed", - ); - } - - async #prepare(signal?: AbortSignal): Promise { - const { handle, initial } = await this.#source.read(signal); + ): Promise { + const { handle, initial } = await this.verifyCarrier(signal); const header = await readGodotPckHeader( handle, initial.size, @@ -196,13 +63,25 @@ export class GodotPckArtifactReader implements ArtifactReader { this.#location, ); this.#header = header; - if (header.directory_status !== "available") return; + if (header.directory_status !== "available") + return { + entries: [], + coverage: { + status: "unknown", + limitations: [ + header.directory_status === "encrypted" + ? "Godot PCK directory is encrypted; member identities and contents remain unknown." + : "Godot PCK sparse-bundle member bytes are external and were not traversed.", + ], + }, + }; const cursor = new PckCursor( handle, header.directory_start + 4, header.pack_end, signal, ); + const entries: PositionalArtifactEntry[] = []; let retainedBytes = 0; let declaredPlaintextBytes = 0n; const expansionBudget = BigInt( @@ -281,41 +160,16 @@ export class GodotPckArtifactReader implements ArtifactReader { ? ("all-zero" as const) : ("declared" as const), }, - } satisfies ArtifactEntry; + } satisfies PositionalArtifactEntry; retainedBytes += jsonValueByteLength( metadata, MAX_DIRECTORY_METADATA_BYTES - retainedBytes, ); if (retainedBytes > MAX_DIRECTORY_METADATA_BYTES) throw metadataLimit(); - this.#entries.set(metadata.adapterKey, metadata); + entries.push(metadata); } - await this.#source.read(signal); - } - - #memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform { - let bytes = 0; - const check = async (): Promise => { - if (bytes !== entry.declaredSize) - throw formatFailure(`member ended early: ${entry.path}`); - await this.#source.read(signal); - }; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - bytes += chunk.length; - callback(null, chunk); - }, - flush(callback) { - void check().then( - () => callback(), - (cause: unknown) => - callback( - cause instanceof Error - ? cause - : new Error("Godot PCK read failed", { cause }), - ), - ); - }, - }); + await this.verifyCarrier(signal); + return { entries, coverage: undefined }; } } diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index a904b49e0f..b5b5f931ef 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -36,7 +36,8 @@ export const DMG_EXISTING_ATTACHMENT_RECOVERY = "hdiutil refused a private read-only DMG attachment after listing an existing attachment of this image path. If it is still attached, ask its owner to release it when safe, then retry. REA does not borrow or detach existing attachments."; const DMG_CHECKSUM_ABSENT_LIMITATION = "hdiutil verify found no stored checksum in this disk image, so its integrity was not verified; the root SHA-256 identifies the inventoried bytes."; -const VERIFY_SETTLE_DELAYS_MS = [250, 500] as const; +const VERIFY_TRANSIENT_DELAY_MS = 250; +const VERIFY_UNKNOWN_SETTLE_DELAYS_MS = [250, 500] as const; // APFS ejects and busy reporting can lag a detach attempt. const DETACH_SETTLE_DELAYS_MS = [250, 500, 1000] as const; const delay = (milliseconds: number): Promise => @@ -545,7 +546,13 @@ export class NativeDmgArtifactReader implements ArtifactReader { }, ); } - const delayMs = VERIFY_SETTLE_DELAYS_MS[attempt]; + // An explicit resource refusal can outlast the initial settle window + // after image creation. Its retries share the existing command deadline; + // an ambiguous localized refusal keeps only the short diagnostic probe. + const delayMs = + cause.verifyDiagnostic === "Resource temporarily unavailable" + ? VERIFY_TRANSIENT_DELAY_MS + : VERIFY_UNKNOWN_SETTLE_DELAYS_MS[attempt]; // Retry only the producer's explicit transient diagnostic, or a localized // refusal of a readable target that may be the same, before any mount. if ( diff --git a/src/artifacts/NodeSeaArtifactReader.boundaries.test.ts b/src/artifacts/NodeSeaArtifactReader.boundaries.test.ts new file mode 100644 index 0000000000..1a172fe30f --- /dev/null +++ b/src/artifacts/NodeSeaArtifactReader.boundaries.test.ts @@ -0,0 +1,8 @@ +import { artifactReaderLifecycleCases } from "../../tests/fixtures/artifactReaderLifecycle.js"; +import { nodeSeaElf } from "./nodeSea/NodeSea.fixture.js"; +import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js"; + +artifactReaderLifecycleCases({ + image: () => nodeSeaElf({ fuse: "set" }), + createReader: (path, admitted) => new NodeSeaArtifactReader(path, admitted), +}); diff --git a/src/artifacts/NodeSeaArtifactReader.test.ts b/src/artifacts/NodeSeaArtifactReader.test.ts index 21984a69f2..45b6950f4a 100644 --- a/src/artifacts/NodeSeaArtifactReader.test.ts +++ b/src/artifacts/NodeSeaArtifactReader.test.ts @@ -17,6 +17,7 @@ import { extractArtifact } from "./extraction/ArtifactExtraction.js"; import { NODE_SEA_BAD_MAGIC, NODE_SEA_DUPLICATE_BLOB, + NODE_SEA_ELF_NOTES_REPEATED, NODE_SEA_FUSE_CONFLICT, NODE_SEA_FUSE_ABSENT, NODE_SEA_FUSE_UNSET, @@ -32,10 +33,11 @@ import { nodeSeaPe, nodeSeaUniversal, } from "./nodeSea/NodeSea.fixture.js"; -import { elfNoteFixture } from "../native/elf/ElfNotes.fixture.js"; +import { elfNote, elfNoteFixture } from "../native/elf/ElfNotes.fixture.js"; import { peResourceFixture } from "../native/pe/PeResources.fixture.js"; import { locateNodeSea } from "./nodeSea/NodeSeaLocation.js"; import { MAX_INSPECT_PE_FILE_BYTES } from "../domain/native/peResources.js"; +import { openRegularFile } from "../filesystem/RegularFile.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; @@ -413,6 +415,69 @@ it("keeps one blob when the same bytes are declared twice", async () => { ).toHaveLength(1); }); +/** ELF64 whose `count` PT_NOTE program headers all declare the same notes. */ +const repeatedNoteElf = (count: number, notes: Buffer): Buffer => { + const noteOffset = 64 + count * 56; + const bytes = Buffer.alloc(noteOffset + notes.length); + bytes.writeUInt32LE(0x464c457f, 0); + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + bytes.writeUInt16LE(3, 0x10); + bytes.writeUInt16LE(62, 0x12); + bytes.writeUInt32LE(1, 0x14); + bytes.writeBigUInt64LE(64n, 0x20); + bytes.writeUInt16LE(64, 0x34); + bytes.writeUInt16LE(56, 0x36); + bytes.writeUInt16LE(count, 0x38); + bytes.writeUInt16LE(64, 0x3a); + for (let index = 0; index < count; index += 1) { + const at = 64 + index * 56; + bytes.writeUInt32LE(4, at); + bytes.writeBigUInt64LE(BigInt(noteOffset), at + 8); + bytes.writeBigUInt64LE(BigInt(notes.length), at + 32); + bytes.writeBigUInt64LE(BigInt(notes.length), at + 40); + bytes.writeBigUInt64LE(4n, at + 48); + } + notes.copy(bytes, noteOffset); + return bytes; +}; + +it("reads repeated PT_NOTE declarations at most once per note the file holds", async () => { + // 4 KiB of zero bytes is 341 empty 12-byte notes. + const empty = Buffer.alloc(4096); + const unconfirmed = repeatedNoteElf(64, empty); + const handle = await open( + await writeCarrier("repeated-notes", unconfirmed), + "r", + ); + const read = vi.spyOn(handle, "read"); + try { + await expect( + locateNodeSea(handle, unconfirmed.length), + ).resolves.toBeUndefined(); + // The prefix, the ELF header and 64 program headers, then the notes. + expect(read.mock.calls.length).toBeLessThanOrEqual( + 2 + 64 + Math.floor(unconfirmed.length / 12), + ); + } finally { + await handle.close(); + } + const blob = buildNodeSeaBlob({ main: script, codePath: "sea.js" }); + const confirmed = repeatedNoteElf( + 64, + Buffer.concat([elfNote("NODE_SEA_BLOB", 0, blob), empty]), + ); + await expect( + scanCanonicalArtifactInventory( + await writeCarrier("repeated-sea-notes", confirmed), + ), + ).rejects.toMatchObject({ + reason: "format", + message: NODE_SEA_ELF_NOTES_REPEATED, + }); +}); + it("extracts the main script and assets without executing them", async () => { const directory = await createTestTempDirectory("rea-node-sea-extract-"); const path = join(directory, "sea.elf"); @@ -619,6 +684,42 @@ it("rejects forged entries and changed carrier identity, then closes idempotentl } }); +it("reports a member that ends after the carrier recheck as an integrity failure", async () => { + const path = await writeCarrier("ended", nodeSeaElf()); + const descriptor = await openRegularFile(path, { symlinks: "reject" }); + const reader = new NodeSeaArtifactReader(path, descriptor); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const entry = entries[0]; + if (entry === undefined) throw new Error("Missing main script"); + // open() rechecks the carrier's identity; the file then ends mid-member. + const read = descriptor.handle.read.bind(descriptor.handle); + let calls = 0; + const truncated = vi + .spyOn(descriptor.handle, "read") + .mockImplementation((...args: unknown[]) => { + calls += 1; + if (calls > 1) + return Promise.resolve({ bytesRead: 0, buffer: args[0] }); + args[2] = 3; + return Reflect.apply(read, descriptor.handle, args); + }); + try { + await expect(buffer(await reader.open(entry))).rejects.toMatchObject({ + reason: "integrity", + message: "Node SEA entry ended early: sea.js", + }); + expect(calls).toBe(2); + } finally { + truncated.mockRestore(); + } + } finally { + await reader.close(); + await descriptor.handle.close(); + } +}); + it.each([8, 1024 * 1024])( "retires an unread %i-byte member through the shared reader factory", async (size) => { diff --git a/src/artifacts/NodeSeaArtifactReader.ts b/src/artifacts/NodeSeaArtifactReader.ts index d4b20ed235..bc86c50a1a 100644 --- a/src/artifacts/NodeSeaArtifactReader.ts +++ b/src/artifacts/NodeSeaArtifactReader.ts @@ -1,5 +1,4 @@ import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js"; -import { Readable, Transform, pipeline } from "node:stream"; import { setImmediate } from "node:timers/promises"; import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; @@ -8,16 +7,7 @@ import { ArtifactPathRegistry, normalizeArtifactPath, } from "./ArtifactPaths.js"; -import { - ArtifactReaderFailure, - copyArtifactEntry, - sameArtifactEntry, - type ArtifactEntry, - type ArtifactReader, - type ArtifactTraversalOptions, -} from "./ArtifactReader.js"; -import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; -import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { NODE_SEA_BLOB_TOO_LARGE, NODE_SEA_MAX_BLOB_BYTES, @@ -31,9 +21,12 @@ import { scanNodeSeaFuse, type NodeSeaLocation, } from "./nodeSea/NodeSeaLocation.js"; -import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; -import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { + PositionalArtifactReader, + type PositionalArtifactEntries, + type PositionalArtifactEntry, +} from "./PositionalArtifactReader.js"; const SNAPSHOT_PATH = "__node_sea__/snapshot"; const CODE_CACHE_PATH = "__node_sea__/code-cache"; @@ -43,27 +36,23 @@ const ASSET_FALLBACK_PREFIX = "__node_sea__/assets/"; type NodeSeaRole = "main-script" | "snapshot" | "code-cache" | "asset"; /** Read a Node.js SEA blob out of a Mach-O, ELF, or PE carrier without running it. */ -export class NodeSeaArtifactReader implements ArtifactReader { - readonly format = "node-sea" as const; - readonly #scope = new ArtifactResourceScope(); - readonly #entries = new Map(); - readonly #streams = new Set(); - readonly #source: ArtifactFileSource; - #prepared: Promise | undefined; +export class NodeSeaArtifactReader extends PositionalArtifactReader { + override readonly format = "node-sea" as const; #header: NodeSeaMetadata | undefined; #admission: NodeSeaAdmission | undefined; - #closing = false; constructor( path: string, admitted?: StableRegularFileDescriptor | NodeSeaAdmission, ) { - if (admitted !== undefined && "kind" in admitted) { - this.#admission = admitted; - this.#source = new ArtifactFileSource(path, "Node SEA", admitted.source); - } else { - this.#source = new ArtifactFileSource(path, "Node SEA", admitted); - } + const admission = + admitted !== undefined && "kind" in admitted ? admitted : undefined; + super( + path, + "Node SEA", + admitted !== undefined && "kind" in admitted ? admitted.source : admitted, + ); + this.#admission = admission; } formatMetadata(): NodeSeaMetadata | undefined { @@ -71,123 +60,16 @@ export class NodeSeaArtifactReader implements ArtifactReader { return { ...this.#header, exec_argv: [...this.#header.exec_argv] }; } - async prepareContainer( - expectedSha256: string, - signal?: AbortSignal, - ): Promise { - await this.#scope.run(async () => { - this.#assertOpen(signal); - await this.#source.bindSha256(expectedSha256, signal); - }); - } - - async *entries( - signal?: AbortSignal, - options?: ArtifactTraversalOptions, - ): AsyncIterable { - await this.#scope.run(async () => { - this.#assertOpen(signal); - this.#prepared ??= this.#prepare(signal); - await this.#prepared; - await this.#source.read(signal); - }); - const contradiction = this.#header?.fuse_contradiction; - if (contradiction !== null && contradiction !== undefined) - options?.onCoverage?.({ - status: "complete", - limitations: [contradiction], - }); - let index = 0; - for (const entry of this.#entries.values()) { - if (index % 256 === 0) await setImmediate(); - index += 1; - this.#assertOpen(signal); - yield copyArtifactEntry(entry); - } - } - - async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { - return this.#scope.run(async () => { - this.#assertOpen(signal); - const stored = this.#entries.get(entry.adapterKey); - if (stored === undefined || !sameArtifactEntry(entry, stored)) - throw new ArtifactReaderFailure( - "integrity", - "Node SEA entry does not match its owning inventory", - ); - const { handle } = await this.#source.read(signal); - this.#assertOpen(signal); - if (stored.byteOffset === null || stored.declaredSize === null) - throw formatFailure("member has no physical byte range"); - const source = - stored.declaredSize === 0 - ? Readable.from([]) - : readFileHandleChunks(handle, { - start: stored.byteOffset, - end: stored.byteOffset + stored.declaredSize - 1, - signal, - cancellationFailure: (reason) => - new ArtifactReaderFailure( - "cancelled", - "Node SEA read cancelled", - { - cause: reason, - }, - ), - }); - const output = this.#memberStream(stored, signal); - output.on("error", () => undefined); - this.#streams.add(output); - // A finished producer can leave source bytes buffered for its consumer. - // Keep the readable side owned until it closes as well. - output.once("close", () => this.#streams.delete(output)); - void this.#scope - .run( - () => - new Promise((resolve) => { - pipeline(source, output, () => { - resolve(); - }); - }), - ) - .catch((cause: unknown) => - output.destroy( - cause instanceof Error - ? cause - : new Error("Node SEA stream ownership failed", { cause }), - ), - ); - return output; - }); - } - - async close(): Promise { - this.#closing = true; - for (const stream of this.#streams) - stream.destroy( - new ArtifactReaderFailure("unavailable", "Node SEA reader closed"), - ); - await this.#scope.close(); - await this.#source.close(); - this.#entries.clear(); + /** Also drop the admitted decode, which only a completed preparation releases. */ + override async close(): Promise { + await super.close(); this.#admission = undefined; } - provenance(): readonly [] { - return []; - } - - #assertOpen(signal?: AbortSignal): void { - assertArtifactReadActive(signal); - if (this.#closing) - throw new ArtifactReaderFailure( - "unavailable", - "Node SEA reader is closed", - ); - } - - async #prepare(signal?: AbortSignal): Promise { - const { handle, initial } = await this.#source.read(signal); + protected override async prepareEntries( + signal?: AbortSignal, + ): Promise { + const { handle, initial } = await this.verifyCarrier(signal); // Freshness remains owned by ArtifactFileSource even when classification // already decoded this exact borrowed descriptor's blob. let location: NodeSeaLocation; @@ -208,6 +90,7 @@ export class NodeSeaArtifactReader implements ArtifactReader { ); } const fuse = await scanNodeSeaFuse(handle, initial.size, signal); + const entries: PositionalArtifactEntry[] = []; let declaredBytes = 0n; const expansionBudget = BigInt( artifactExpansionBudgetBytes(location.blobLength), @@ -215,7 +98,7 @@ export class NodeSeaArtifactReader implements ArtifactReader { let index = 0; for (const member of seaMembers(decoded)) { if (index % 256 === 0) await setImmediate(); - this.#assertOpen(signal); + this.assertOpen(signal); declaredBytes += BigInt(member.range.length); if (declaredBytes > expansionBudget) throw new ArtifactReaderFailure( @@ -245,8 +128,8 @@ export class NodeSeaArtifactReader implements ArtifactReader { index, }, sourcePath: member.sourcePath, - } satisfies ArtifactEntry; - this.#entries.set(entry.adapterKey, entry); + } satisfies PositionalArtifactEntry; + entries.push(entry); index += 1; } this.#header = { @@ -268,34 +151,15 @@ export class NodeSeaArtifactReader implements ArtifactReader { fuse_sentinel: fuse.fuseSentinel, fuse_contradiction: fuse.fuseContradiction, }; - await this.#source.read(signal); + await this.verifyCarrier(signal); this.#admission = undefined; - } - - #memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform { - let bytes = 0; - const check = async (): Promise => { - if (bytes !== entry.declaredSize) - throw formatFailure(`member ended early: ${entry.path}`); - await this.#source.read(signal); + return { + entries, + coverage: + fuse.fuseContradiction === null + ? undefined + : { status: "complete", limitations: [fuse.fuseContradiction] }, }; - return new Transform({ - transform(chunk: Buffer, _encoding, callback) { - bytes += chunk.length; - callback(null, chunk); - }, - flush(callback) { - void check().then( - () => callback(), - (cause: unknown) => - callback( - cause instanceof Error - ? cause - : new Error("Node SEA read failed", { cause }), - ), - ); - }, - }); } } diff --git a/src/artifacts/PositionalArtifactReader.ts b/src/artifacts/PositionalArtifactReader.ts new file mode 100644 index 0000000000..7b26e2420f --- /dev/null +++ b/src/artifacts/PositionalArtifactReader.ts @@ -0,0 +1,256 @@ +import { Readable, Transform, pipeline } from "node:stream"; +import { setImmediate } from "node:timers/promises"; + +import { + ArtifactReaderFailure, + copyArtifactEntry, + sameArtifactEntry, + type ArtifactEntry, + type ArtifactReader, + type ArtifactReaderTraversal, + type ArtifactTraversalOptions, +} from "./ArtifactReader.js"; +import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; + +/** An entry whose bytes are one range of the carrier. */ +export type PositionalArtifactEntry = ArtifactEntry & { + readonly byteOffset: number; + readonly declaredSize: number; +}; + +/** The entries one preparation established and the coverage to report. */ +export interface PositionalArtifactEntries { + readonly entries: readonly PositionalArtifactEntry[]; + /** Undefined when coverage is complete without limitations. */ + readonly coverage: ArtifactReaderTraversal | undefined; +} + +/** + * Serve entries that are byte ranges of one carrier file. Preparation runs + * once; every traversal, open and completed entry stream rechecks the + * carrier's identity, and close retires open streams before the carrier. + * Encrypted entries are listed but never opened. + */ +export abstract class PositionalArtifactReader implements ArtifactReader { + abstract readonly format: ArtifactReader["format"]; + readonly #scope = new ArtifactResourceScope(); + readonly #entries = new Map(); + readonly #streams = new Set(); + readonly #label: string; + readonly #source: ArtifactFileSource; + #prepared: Promise | undefined; + #coverage: ArtifactReaderTraversal | undefined; + #closing = false; + + constructor( + path: string, + label: string, + admitted?: StableRegularFileDescriptor, + ) { + this.#label = label; + this.#source = new ArtifactFileSource(path, label, admitted); + } + + /** Read the carrier once and describe its entries. */ + protected abstract prepareEntries( + signal?: AbortSignal, + ): Promise; + + async prepareContainer( + expectedSha256: string, + signal?: AbortSignal, + ): Promise { + await this.#scope.run(async () => { + this.assertOpen(signal); + await this.#source.bindSha256(expectedSha256, signal); + }); + } + + async *entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable { + await this.#scope.run(async () => { + this.assertOpen(signal); + this.#prepared ??= this.#prepare(signal); + await this.#prepared; + await this.#source.read(signal); + }); + this.assertOpen(signal); + if (this.#coverage !== undefined) + options?.onCoverage?.({ + ...this.#coverage, + limitations: [...this.#coverage.limitations], + }); + let index = 0; + for (const entry of this.#entries.values()) { + // Yield between batches so a large inventory does not hold the loop. + if (index++ % 256 === 0) await setImmediate(); + this.assertOpen(signal); + yield copyArtifactEntry(entry); + } + this.assertOpen(signal); + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + return this.#scope.run(async () => { + this.assertOpen(signal); + const stored = this.#entries.get(entry.adapterKey); + if (stored === undefined || !sameArtifactEntry(entry, stored)) + throw new ArtifactReaderFailure( + "integrity", + `${this.#label} entry does not match its owning inventory`, + ); + if (stored.encrypted) + throw new ArtifactReaderFailure( + "unavailable", + `Encrypted ${this.#label} member is unsupported: ${stored.path}`, + ); + const { handle } = await this.#source.read(signal); + this.assertOpen(signal); + const source = + stored.declaredSize === 0 + ? Readable.from([]) + : readFileHandleChunks(handle, { + start: stored.byteOffset, + end: stored.byteOffset + stored.declaredSize - 1, + signal, + cancellationFailure: (reason) => + new ArtifactReaderFailure( + "cancelled", + `${this.#label} read cancelled`, + { cause: reason }, + ), + }); + const output = this.#entryStream(stored, signal); + // Retirement can destroy the stream before its consumer listens. + output.on("error", () => undefined); + this.#streams.add(output); + let producerComplete = false; + const onAbort = (): void => { + // Preserve active read/validation errors; after production, the caller + // still owns buffered bytes and cancellation must retire that result. + if (!producerComplete || output.destroyed) return; + output.destroy( + new ArtifactReaderFailure( + "cancelled", + `${this.#label} read cancelled`, + { cause: signal?.reason }, + ), + ); + }; + // Writable completion can leave unread bytes buffered on the readable + // side, so the stream stays owned until that side closes too. + const retired = new Promise((resolve) => { + output.once("close", () => { + this.#streams.delete(output); + signal?.removeEventListener("abort", onAbort); + resolve(); + }); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + void this.#scope + .run(async () => { + await Promise.all([ + new Promise((resolve) => { + pipeline(source, output, () => { + producerComplete = true; + if (signal?.aborted) onAbort(); + resolve(); + }); + }), + retired, + ]); + }) + .catch((cause: unknown) => + output.destroy( + cause instanceof Error + ? cause + : new Error(`${this.#label} stream ownership failed`, { + cause, + }), + ), + ); + return output; + }); + } + + async close(): Promise { + this.#closing = true; + for (const stream of this.#streams) + stream.destroy( + new ArtifactReaderFailure( + "unavailable", + `${this.#label} reader closed`, + ), + ); + await this.#scope.close(); + await this.#source.close(); + this.#entries.clear(); + } + + provenance(): readonly [] { + return []; + } + + /** Recheck the carrier's identity and borrow its descriptor. */ + protected verifyCarrier( + signal?: AbortSignal, + ): Promise { + return this.#source.read(signal); + } + + /** Refuse work once the caller cancels or close begins. */ + protected assertOpen(signal?: AbortSignal): void { + assertArtifactReadActive(signal); + if (this.#closing) + throw new ArtifactReaderFailure( + "unavailable", + `${this.#label} reader is closed`, + ); + } + + async #prepare(signal?: AbortSignal): Promise { + const { entries, coverage } = await this.prepareEntries(signal); + for (const entry of entries) this.#entries.set(entry.adapterKey, entry); + this.#coverage = coverage; + } + + #entryStream( + entry: PositionalArtifactEntry, + signal?: AbortSignal, + ): Transform { + const label = this.#label; + let bytes = 0; + const check = async (): Promise => { + if (bytes !== entry.declaredSize) + throw new ArtifactReaderFailure( + "integrity", + `${label} entry ended early: ${entry.path}`, + ); + await this.#source.read(signal); + }; + return new Transform({ + transform(chunk: Buffer, _encoding, callback) { + bytes += chunk.length; + callback(null, chunk); + }, + flush(callback) { + void check().then( + () => callback(), + (cause: unknown) => + callback( + cause instanceof Error + ? cause + : new Error(`${label} read failed`, { cause }), + ), + ); + }, + }); + } +} diff --git a/src/artifacts/StableArtifactFailure.ts b/src/artifacts/StableArtifactFailure.ts index 0c4fb62751..4d326b2927 100644 --- a/src/artifacts/StableArtifactFailure.ts +++ b/src/artifacts/StableArtifactFailure.ts @@ -5,6 +5,15 @@ import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { bindArtifactFileCleanup } from "./ArtifactFileOperation.js"; import { completedStableArtifactObservation } from "./StableArtifactObservation.js"; +/** Offer the verified regular-file target after a selected symlink is rejected. */ +export const stableArtifactInputRecovery = (cause: ArtifactReaderFailure) => + cause.reason === "path" && cause.resolvedPath !== undefined + ? { + remediationAction: `Retry with the resolved regular file path ${cause.resolvedPath}. The selected symlink is not admitted; the retry will validate the target again.`, + conformsToInputContract: true, + } + : {}; + const artifactCleanupFailure = ( cause: unknown, providerId: string, diff --git a/src/artifacts/WxapkgArtifactReader.boundaries.test.ts b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts new file mode 100644 index 0000000000..5d8b69eb9c --- /dev/null +++ b/src/artifacts/WxapkgArtifactReader.boundaries.test.ts @@ -0,0 +1,310 @@ +import { once } from "node:events"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer, text } from "node:stream/consumers"; +import { setImmediate } from "node:timers/promises"; +import { describe, expect, it, vi } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { createDeferred } from "../../tests/fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; +import { buildWxapkg } from "../../tests/fixtures/wxapkg.js"; +import { openRegularFile } from "../filesystem/RegularFile.js"; +import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; +import { WxapkgArtifactReader } from "./WxapkgArtifactReader.js"; + +const fixture = async (bytes: Buffer): Promise => { + const path = join( + await createTestTempDirectory("rea-wxapkg-boundary-"), + "package.wxapkg", + ); + await writeFile(path, bytes); + return path; +}; +const entries = async (reader: WxapkgArtifactReader) => { + const result = []; + for await (const entry of reader.entries()) result.push(entry); + return result; +}; + +describe("wxapkg allocation and malformed-input boundaries", () => { + it("admits the encoded table budget before reading it", async () => { + const bytes = buildWxapkg([{ path: "a", data: "x" }]); + bytes.writeUInt32BE(17 * 1024 * 1024, 5); + const file = Buffer.alloc(18 + 17 * 1024 * 1024); + bytes.copy(file); + const path = await fixture(file); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new WxapkgArtifactReader(path, admitted); + const reads = vi.spyOn(admitted.handle, "read"); + try { + await expect(entries(reader)).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + }); + expect(reads).toHaveBeenCalledTimes(1); // bounded header only + } finally { + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } + }); + + it("reserves row storage before constructing a large member array", async () => { + // 140k minimal rows fit the encoded table budget but exceed the retained + // directory representation budget mid-parse. + const fileCount = 140_000; + const tableBytes = fileCount * 13; + const file = Buffer.alloc(18 + tableBytes); + file[0] = 0xbe; + file.writeUInt32BE(0, 1); + file.writeUInt32BE(tableBytes, 5); + file.writeUInt32BE(0, 9); + file[13] = 0xed; + file.writeUInt32BE(fileCount, 14); + for (let index = 0; index < fileCount; index++) { + const row = 18 + index * 13; + file.writeUInt32BE(1, row); + file[row + 4] = 0x61; + // Zero-size members at the data start stay admissible so parsing + // reaches the representation budget. + file.writeUInt32BE(18 + tableBytes, row + 5); + file.writeUInt32BE(0, row + 9); + } + const reader = new WxapkgArtifactReader(await fixture(file)); + try { + await expect(entries(reader)).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + }); + } finally { + await reader.close(); + } + }); + + it("charges overlapping readable members, not only their unique range", async () => { + const size = 1024 * 1024; + const members = Array.from({ length: 600 }, (_, index) => ({ + path: `alias-${String(index)}`, + data: index === 0 ? Buffer.alloc(size, 0x61) : Buffer.alloc(0), + size, + })); + const reader = new WxapkgArtifactReader( + await fixture(buildWxapkg(members, { aliasOffsets: true })), + ); + try { + await expect(entries(reader)).rejects.toMatchObject({ reason: "limit" }); + } finally { + await reader.close(); + } + }); + + it("types a short later row after an overstated first name consumes its bytes", async () => { + const bytes = buildWxapkg([ + { path: "a".repeat(40), data: "x" }, + { path: "b", data: "y" }, + ]); + // The first row's declared name consumes most of the second row. The + // minimum total table-size check alone cannot establish each later row. + bytes.writeUInt32BE(50, 18); + // Keep the first member admissible at the position the parser reads after + // the inflated name, so the failure must come from the short later row. + const dataStart = 18 + bytes.readUInt32BE(5); + bytes.writeUInt32BE(dataStart, 18 + 4 + 50); + bytes.writeUInt32BE(1, 18 + 4 + 50 + 4); + const reader = new WxapkgArtifactReader(await fixture(bytes)); + try { + await expect(entries(reader)).rejects.toMatchObject({ + reason: "format", + message: expect.stringContaining("truncated"), + }); + } finally { + await reader.close(); + } + }); +}); + +describe("wxapkg stream ownership", () => { + it("retains descriptor ownership after cleanup fails and permits retry", async () => { + const reader = new WxapkgArtifactReader( + await fixture(buildWxapkg([{ path: "x", data: "bytes" }])), + ); + const [entry] = await entries(reader); + const stream = await reader.open(entry!); + const failure = new Error("descriptor close denied"); + const close = vi + .spyOn(OwnedFileHandle.prototype, "close") + .mockRejectedValueOnce(failure); + try { + await expect(reader.close()).rejects.toBe(failure); + expect(stream.closed).toBe(true); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "unavailable", + }); + await reader.close(); + expect(close).toHaveBeenCalledTimes(2); + } finally { + close.mockRestore(); + await reader.close(); + } + }); + + it("joins an outstanding positional read before close completes", async () => { + const path = await fixture(buildWxapkg([{ path: "x", data: "bytes" }])); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new WxapkgArtifactReader(path, admitted); + const reached = createDeferred(), + release = createDeferred(); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const [entry] = await entries(reader); + const reads = vi.spyOn(admitted.handle, "read").mockImplementation((async ( + ...args: unknown[] + ) => { + reached.resolve(); + await release.promise; + return Reflect.apply(nativeRead, admitted.handle, args); + }) as typeof admitted.handle.read); + try { + const stream = await reader.open(entry!); + stream.read(0); + await awaitAcknowledgement( + reached.promise, + once(stream, "close"), + "positional member read", + ); + let closed = false; + const closing = reader.close().then(() => { + closed = true; + }); + await setImmediate(); + expect(closed).toBe(false); + release.resolve(); + await closing; + expect(stream.closed).toBe(true); + } finally { + release.resolve(); + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } + }); + + it("preserves cancellation inside a member read", async () => { + const data = Buffer.alloc(256 * 1024, 0x61); + const path = await fixture(buildWxapkg([{ path: "x", data }])); + const admitted = await openRegularFile(path, { symlinks: "reject" }); + const reader = new WxapkgArtifactReader(path, admitted); + const controller = new AbortController(); + const reason = new Error("cancel during member read"); + const nativeRead = admitted.handle.read.bind(admitted.handle); + const [entry] = await entries(reader); + const reads = vi.spyOn(admitted.handle, "read").mockImplementation((async ( + ...args: unknown[] + ) => { + const result = await Reflect.apply(nativeRead, admitted.handle, args); + globalThis.setImmediate(() => { + controller.abort(reason); + }); + return result; + }) as typeof admitted.handle.read); + try { + await expect( + buffer(await reader.open(entry!, controller.signal)), + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); + } finally { + await reader.close(); + reads.mockRestore(); + await admitted.handle.close(); + } + }); +}); + +describe("wxapkg stream capacity and freshness", () => { + it("bounds active streams and releases capacity after abandonment", async () => { + const reader = new WxapkgArtifactReader( + await fixture(buildWxapkg([{ path: "x", data: "bytes" }])), + ); + try { + const [entry] = await entries(reader); + const streams = await Promise.all( + Array.from({ length: 4 }, () => reader.open(entry!)), + ); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "limit", + boundary: "capacity", + }); + const closed = new Promise((resolve) => { + streams[0]!.once("close", resolve); + }); + streams[0]!.destroy(); + await closed; + expect(await text(await reader.open(entry!))).toBe("bytes"); + } finally { + await reader.close(); + } + }); + + it("bounds repeated member I/O operations, including empty members", async () => { + const reader = new WxapkgArtifactReader( + await fixture( + buildWxapkg([ + { path: "x", data: "b" }, + { path: "empty.wxss", data: "" }, + ]), + ), + ); + try { + const [entry, emptyEntry] = await entries(reader); + await expect( + (async () => { + for (let index = 0; index < 5000; index++) + await buffer(await reader.open(entry!)); + })(), + ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); + await expect( + (async () => { + for (let index = 0; index < 5000; index++) + await buffer(await reader.open(emptyEntry!)); + })(), + ).rejects.toMatchObject({ reason: "limit", boundary: "capacity" }); + } finally { + await reader.close(); + } + }); + + it("validates freshness before serving member bytes", async () => { + const bytes = buildWxapkg([{ path: "x", data: "before" }]); + const path = await fixture(bytes); + const reader = new WxapkgArtifactReader(path); + try { + const [entry] = await entries(reader); + expect(await text(await reader.open(entry!))).toBe("before"); + await writeFile(path, Buffer.concat([bytes, Buffer.from([0])])); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await reader.close(); + } + }); + + it("does not turn a paused inventory into complete coverage when closed", async () => { + const reader = new WxapkgArtifactReader( + await fixture( + buildWxapkg([ + { path: "x", data: "bytes" }, + { path: "y", data: "bytes" }, + ]), + ), + ); + const iterator = reader.entries()[Symbol.asyncIterator](); + try { + expect((await iterator.next()).done).toBe(false); + await reader.close(); + await expect(iterator.next()).rejects.toMatchObject({ + reason: "unavailable", + }); + } finally { + await reader.close(); + } + }); +}); diff --git a/src/artifacts/WxapkgArtifactReader.test.ts b/src/artifacts/WxapkgArtifactReader.test.ts new file mode 100644 index 0000000000..51205da61a --- /dev/null +++ b/src/artifacts/WxapkgArtifactReader.test.ts @@ -0,0 +1,468 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { text } from "node:stream/consumers"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { + buildWxapkg, + wxapkgEncryptedBytes, + type WxapkgMember, +} from "../../tests/fixtures/wxapkg.js"; + +import type { + ArtifactEntry, + ArtifactTraversalOptions, +} from "./ArtifactReader.js"; +import { hasWxapkgHeader } from "../domain/wxapkgFormat.js"; +import { WxapkgArtifactReader } from "./WxapkgArtifactReader.js"; + +const writePackage = async (bytes: Buffer): Promise => { + const path = join( + await createTestTempDirectory("rea-wxapkg-"), + "package.wxapkg", + ); + await writeFile(path, bytes); + return path; +}; + +const list = async ( + reader: WxapkgArtifactReader, + options?: ArtifactTraversalOptions, +): Promise => { + const entries: ArtifactEntry[] = []; + for await (const entry of reader.entries(undefined, options)) + entries.push(entry); + return entries; +}; + +const withReader = async ( + bytes: Buffer, + use: (reader: WxapkgArtifactReader) => Promise, +): Promise => { + const reader = new WxapkgArtifactReader(await writePackage(bytes)); + try { + return await use(reader); + } finally { + await reader.close(); + } +}; + +const members: readonly WxapkgMember[] = [ + { path: "app-config.json", data: "{}" }, + { path: "pages/index/index.js", data: "module.exports={};" }, + { path: "empty.wxss", data: "" }, +]; + +describe("WxapkgArtifactReader", () => { + it("lists members and reads their declared bytes", async () => { + await withReader(buildWxapkg(members), async (reader) => { + const entries = await list(reader); + expect(entries.map(({ path }) => path)).toEqual([ + "app-config.json", + "pages/index/index.js", + "empty.wxss", + ]); + expect(await text(await reader.open(entries[0]!))).toBe("{}"); + expect(await text(await reader.open(entries[1]!))).toBe( + "module.exports={};", + ); + expect(await text(await reader.open(entries[2]!))).toBe(""); + }); + }); + + it("preserves member provenance on entries and metadata", async () => { + await withReader(buildWxapkg(members), async (reader) => { + const entries = await list(reader); + const entry = entries[1]!; + expect(entry).toMatchObject({ + path: "pages/index/index.js", + sourcePath: "pages/index/index.js", + kind: "file", + declaredSize: 18, + compressedSize: null, + encrypted: false, + declaredIntegrity: null, + unpacked: false, + adapterKey: "wxapkg-entry:1", + formatMetadata: { + kind: "wxapkg-entry", + index: 1, + source_path: "pages/index/index.js", + }, + }); + // Member offsets are absolute carrier positions after the table. + expect(entry.byteOffset).toBeGreaterThanOrEqual(18); + expect(reader.formatMetadata()).toMatchObject({ + kind: "wxapkg", + file_count: 3, + directory_status: "available", + }); + }); + }); + + it("reads a member larger than the chunk size", async () => { + const data = Buffer.alloc(200 * 1024, 0x61); + await withReader( + buildWxapkg([{ path: "big.bin", data }]), + async (reader) => { + const [entry] = await list(reader); + const chunks: Buffer[] = []; + for await (const chunk of await reader.open(entry!)) + chunks.push(chunk as Buffer); + expect(Buffer.concat(chunks).equals(data)).toBe(true); + }, + ); + }); + + it("accepts consecutive dots and unicode names", async () => { + await withReader( + buildWxapkg([ + { path: "data..bin", data: "a" }, + { path: "页面/配置.json", data: "b" }, + ]), + async (reader) => { + const entries = await list(reader); + expect(entries[0]?.path).toBe("data..bin"); + expect(entries[1]?.path).toBe("页面/配置.json"); + expect(await text(await reader.open(entries[1]!))).toBe("b"); + }, + ); + }); + + it("translates producer root prefixes while keeping declared names", async () => { + await withReader( + buildWxapkg([ + { path: "/app-config.json", data: "{}" }, + { path: "/pages/index/index.js", data: "x=1;" }, + ]), + async (reader) => { + const entries = await list(reader); + expect(entries.map((entry) => entry.path)).toEqual([ + "app-config.json", + "pages/index/index.js", + ]); + expect(entries[1]).toMatchObject({ + sourcePath: "/pages/index/index.js", + formatMetadata: { source_path: "/pages/index/index.js" }, + }); + expect(await text(await reader.open(entries[0]!))).toBe("{}"); + }, + ); + }); + + it("exposes no members for a V1MMWX-encrypted package", async () => { + await withReader(wxapkgEncryptedBytes(), async (reader) => { + const coverage: { + status?: string; + limitations?: readonly string[]; + } = {}; + const entries = await list(reader, { + onCoverage: (observation) => { + coverage.status = observation.status; + coverage.limitations = observation.limitations; + }, + }); + expect(entries).toEqual([]); + expect(coverage.status).toBe("unknown"); + expect(coverage.limitations?.join(" ")).toContain("V1MMWX"); + expect(reader.formatMetadata()).toMatchObject({ + kind: "wxapkg", + directory_status: "encrypted", + file_count: null, + info: null, + }); + }); + }); +}); + +describe("WxapkgArtifactReader malformed inputs", () => { + it.each([ + ["wrong first mark", { firstMark: 0x00 }], + ["wrong last mark", { lastMark: 0x00 }], + ["file count above the row count", { declaredFileCount: 4 }], + ["index length below the rows", { declaredIndexLength: 10 }], + ["declared name exceeds the row", { nameLength: 1024 }], + ["zero-length declared name", { nameLength: 0 }], + ])("rejects %s as a typed failure", async (_name, options) => { + await withReader(buildWxapkg(members, options), async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ + reason: expect.stringMatching(/^(format|limit)$/), + }); + }); + }); + + it("rejects a member offset into the header region", async () => { + await withReader( + buildWxapkg([{ path: "evil.bin", data: "x", offset: 4 }]), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ + reason: "format", + }); + }, + ); + }); + + it("rejects a member extent beyond the file", async () => { + await withReader( + buildWxapkg([{ path: "evil.bin", data: "x", size: 1 << 20 }]), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ + reason: "format", + }); + }, + ); + }); + + it("rejects a member offset into the index region", async () => { + // Offset 18 is inside the declared table, not the data region. + await withReader( + buildWxapkg([{ path: "evil.bin", data: "x", offset: 18, size: 1 }]), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }, + ); + }); +}); + +describe("WxapkgArtifactReader data region", () => { + it("rejects a member range that covers unused index bytes", async () => { + const bytes = buildWxapkg([{ path: "a", data: "Z" }], { + tablePadding: 8, + }); + // The row ends at byte 31; the next eight bytes are still the index. + bytes.writeUInt32BE(31, 23); + bytes.writeUInt32BE(1, 27); + await withReader(bytes, async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }); + }); + + it("reads member bytes that follow unused index bytes", async () => { + await withReader( + buildWxapkg([{ path: "a", data: "Z" }], { tablePadding: 8 }), + async (reader) => { + const [entry] = await list(reader); + expect(entry).toMatchObject({ byteOffset: 39, declaredSize: 1 }); + expect(await text(await reader.open(entry!))).toBe("Z"); + }, + ); + }); + + it("rejects a member extent beyond the declared data region", async () => { + await withReader( + buildWxapkg([{ path: "x", data: "0123456789" }], { + declaredDataLength: 5, + }), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }, + ); + }); + + it("rejects a data region declared past the file", async () => { + await withReader( + buildWxapkg([{ path: "x", data: "ok" }], { + declaredDataLength: 1 << 24, + }), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }, + ); + }); +}); + +describe("WxapkgArtifactReader index length", () => { + it("reads a one-byte member when indexLength counts only the rows", async () => { + const bytes = buildWxapkg([{ path: "a", data: "Z" }]); + expect(bytes).toHaveLength(32); + expect(bytes.readUInt32BE(5)).toBe(13); + await withReader(bytes, async (reader) => { + const [entry] = await list(reader); + expect(entry).toMatchObject({ byteOffset: 31, declaredSize: 1 }); + expect(reader.formatMetadata()).toMatchObject({ + index_length: 13, + data_length: 1, + file_count: 1, + }); + expect(await text(await reader.open(entry!))).toBe("Z"); + }); + }); + + it("reads a one-byte member when indexLength includes the member count", async () => { + const bytes = buildWxapkg([{ path: "a", data: "Z" }], { + indexLengthIncludesFileCount: true, + }); + expect(bytes).toHaveLength(32); + expect(bytes.readUInt32BE(5)).toBe(17); + await withReader(bytes, async (reader) => { + const [entry] = await list(reader); + expect(entry).toMatchObject({ + path: "a", + byteOffset: 31, + declaredSize: 1, + }); + expect(reader.formatMetadata()).toMatchObject({ + index_length: 17, + data_length: 1, + file_count: 1, + }); + expect(await text(await reader.open(entry!))).toBe("Z"); + }); + }); + + it("reads a count-inclusive member when exactly four bytes trail the package", async () => { + const bytes = Buffer.concat([ + buildWxapkg([{ path: "a", data: "Z" }], { + indexLengthIncludesFileCount: true, + }), + Buffer.alloc(4, 0xee), + ]); + expect(bytes).toHaveLength(36); + await withReader(bytes, async (reader) => { + const [entry] = await list(reader); + expect(entry).toMatchObject({ byteOffset: 31, declaredSize: 1 }); + expect(await text(await reader.open(entry!))).toBe("Z"); + }); + }); + + it("rejects a count-inclusive member that covers trailing bytes", async () => { + const bytes = Buffer.concat([ + buildWxapkg([{ path: "a", data: "Z" }], { + indexLengthIncludesFileCount: true, + }), + Buffer.alloc(4, 0xee), + ]); + bytes.writeUInt32BE(5, 27); + await withReader(bytes, async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }); + }); + + it("keeps a count-inclusive member inside the data region when bytes trail the package", async () => { + const bytes = Buffer.concat([ + buildWxapkg([{ path: "a", data: "Z" }], { + indexLengthIncludesFileCount: true, + }), + Buffer.alloc(8, 0xee), + ]); + await withReader(bytes, async (reader) => { + const [entry] = await list(reader); + expect(entry).toMatchObject({ byteOffset: 31, declaredSize: 1 }); + expect(await text(await reader.open(entry!))).toBe("Z"); + }); + }); + + it("rejects an index byte of a count-inclusive package", async () => { + await withReader( + buildWxapkg([{ path: "a", data: "Z", offset: 18, size: 1 }], { + indexLengthIncludesFileCount: true, + }), + async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }, + ); + }); +}); + +describe("WxapkgArtifactReader damaged packages", () => { + it("keeps a damaged package's format identity as a typed failure", async () => { + // Marks hold but the declared count cannot fit the declared table. + const bytes = buildWxapkg(members, { + declaredFileCount: 2, + declaredIndexLength: 12, + }); + expect(hasWxapkgHeader(bytes)).toBe(true); + await withReader(bytes, async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }); + }); + + it("rejects a truncated package before the declared table ends", async () => { + const bytes = buildWxapkg(members); + await withReader(bytes.subarray(0, 30), async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }); + }); + + it("rejects a non-UTF-8 member name as a typed format failure", async () => { + const bytes = buildWxapkg(members); + bytes[26] = 0xff; // first byte of the first member name + await withReader(bytes, async (reader) => { + await expect(list(reader)).rejects.toMatchObject({ reason: "format" }); + }); + }); + + it("rejects a forged foreign entry with an integrity failure", async () => { + await withReader(buildWxapkg(members), async (reader) => { + const [entry] = await list(reader); + await expect( + reader.open({ ...entry!, adapterKey: "wxapkg-entry:9" }), + ).rejects.toMatchObject({ reason: "integrity" }); + }); + }); +}); + +describe("WxapkgArtifactReader lifecycle", () => { + it("aborts member reads when the caller cancels", async () => { + const data = Buffer.alloc(300 * 1024, 0x62); + await withReader( + buildWxapkg([{ path: "big.bin", data }]), + async (reader) => { + const [entry] = await list(reader); + const controller = new AbortController(); + const stream = await reader.open(entry!, controller.signal); + let delivered = 0; + await expect( + (async () => { + for await (const chunk of stream) { + delivered += (chunk as Buffer).length; + controller.abort(); + } + })(), + ).rejects.toThrow(); + expect(delivered).toBeLessThan(data.length); + }, + ); + }); + + it("survives a destroyed member stream and serves the next entry", async () => { + await withReader(buildWxapkg(members), async (reader) => { + const entries = await list(reader); + const abandoned = await reader.open(entries[0]!); + abandoned.destroy(); + await expect(text(await reader.open(entries[1]!))).resolves.toBe( + "module.exports={};", + ); + }); + }); + + it("fails streams and later reads after close", async () => { + const reader = new WxapkgArtifactReader( + await writePackage(buildWxapkg(members)), + ); + const entries: ArtifactEntry[] = []; + for await (const entry of reader.entries()) entries.push(entry); + const stream = await reader.open(entries[0]!); + const consumed = text(stream).catch((error: unknown) => error); + await reader.close(); + expect(await consumed).toBeInstanceOf(Error); + await expect(reader.open(entries[1]!)).rejects.toMatchObject({ + reason: "unavailable", + }); + }); + + it("bounds concurrent member streams", async () => { + await withReader(buildWxapkg(members), async (reader) => { + const [entry] = await list(reader); + const streams = await Promise.all( + [0, 1, 2, 3].map(() => reader.open(entry!)), + ); + await expect(reader.open(entry!)).rejects.toMatchObject({ + reason: "limit", + }); + for (const stream of streams) stream.destroy(); + }); + }); +}); diff --git a/src/artifacts/WxapkgArtifactReader.ts b/src/artifacts/WxapkgArtifactReader.ts new file mode 100644 index 0000000000..fc7c57a3b1 --- /dev/null +++ b/src/artifacts/WxapkgArtifactReader.ts @@ -0,0 +1,311 @@ +import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js"; +import type { FileHandle } from "node:fs/promises"; +import { setImmediate } from "node:timers/promises"; +import { Readable } from "node:stream"; +import { + ArtifactReaderFailure, + artifactFixedLimitFailure, + copyArtifactEntry, + sameArtifactEntry, + type ArtifactEntry, + type ArtifactReader, + type ArtifactTraversalOptions, +} from "./ArtifactReader.js"; +import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { readArtifactFileBytes } from "./ArtifactFileRange.js"; +import type { + ArtifactFormatMetadata, + WxapkgMetadata, +} from "../domain/artifactFormatMetadata.js"; +import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js"; +import { ArtifactFileSource } from "./ArtifactFileSource.js"; +import { + readWxapkgFileTable, + readWxapkgHeader, + wxapkgFormatFailure, + type WxapkgMember, +} from "./WxapkgStructures.js"; + +const CHUNK_BYTES = 64 * 1024; +/** Bound active member streams; each owns a file-range generator. */ +const MAX_MEMBER_STREAMS = 4; + +type WxapkgEntry = ArtifactEntry & { + readonly declaredSize: number; + readonly formatMetadata: Extract< + ArtifactFormatMetadata, + { kind: "wxapkg-entry" } + >; +}; + +interface WxapkgEntryState { + readonly entry: WxapkgEntry; + readonly member: WxapkgMember; +} + +/** One descriptor, one admitted member table and bounded raw-range member reads. */ +export class WxapkgArtifactReader implements ArtifactReader { + readonly format = "wxapkg" as const; + readonly #scope = new ArtifactResourceScope(); + readonly #entries = new Map(); + readonly #streams = new Map(); + readonly #source: ArtifactFileSource; + #prepared: Promise | undefined; + #header: WxapkgMetadata | undefined; + #remainingReadOperations = 0; + #admittedReadOperations = 0; + #closing = false; + + constructor(path: string, admitted?: StableRegularFileDescriptor) { + this.#source = new ArtifactFileSource(path, "wxapkg package", admitted); + } + + formatMetadata(): WxapkgMetadata | undefined { + return this.#header === undefined ? undefined : { ...this.#header }; + } + + async prepareContainer( + expectedSha256: string, + signal?: AbortSignal, + ): Promise { + await this.#scope.run(async () => { + this.#assertOpen(signal); + await this.#source.bindSha256(expectedSha256, signal); + }); + } + + async *entries( + signal?: AbortSignal, + options?: ArtifactTraversalOptions, + ): AsyncIterable { + await this.#scope.run(async () => { + this.#assertOpen(signal); + this.#prepared ??= this.#prepare(signal); + await this.#prepared; + await this.#source.read(signal); + }); + this.#assertOpen(signal); + if (this.#header?.directory_status === "encrypted") { + options?.onCoverage?.({ + status: "unknown", + limitations: [ + "wxapkg package is V1MMWX-encrypted; member identities and contents remain unknown.", + ], + }); + return; + } + let index = 0; + for (const { entry } of this.#entries.values()) { + if (index++ % 256 === 0) await setImmediate(); + this.#assertOpen(signal); + yield copyArtifactEntry(entry); + } + // A close while paused at yield must not turn a cleared map into completion. + this.#assertOpen(signal); + } + + async open(entry: ArtifactEntry, signal?: AbortSignal): Promise { + return this.#scope.run(async () => { + this.#assertOpen(signal); + const state = this.#entries.get(entry.adapterKey); + if (state === undefined || !sameArtifactEntry(entry, state.entry)) + throw new ArtifactReaderFailure( + "integrity", + "wxapkg entry does not match its owning inventory", + ); + // Every logical open spends the work budget, including empty members + // whose streams perform only the carrier checks below. + this.#reserveMemberRead(); + // Validate carrier freshness before handing out member bytes. + const { handle } = await this.#source.read(signal); + this.#assertOpen(signal); + if (this.#streams.size >= MAX_MEMBER_STREAMS) + throw new ArtifactReaderFailure( + "limit", + "wxapkg reader already owns four active member streams", + { boundary: "capacity" }, + ); + const controller = new AbortController(); + const output = Readable.from( + this.#memberChunks(handle, state, controller.signal), + { + objectMode: false, + highWaterMark: CHUNK_BYTES, + }, + ); + const onAbort = (): void => { + controller.abort(signal?.reason); + output.destroy( + new ArtifactReaderFailure( + "cancelled", + "wxapkg member read cancelled", + { cause: signal?.reason }, + ), + ); + }; + output.on("error", () => undefined); + this.#streams.set(output, controller); + const completion = new Promise((resolve) => { + output.once("close", () => { + signal?.removeEventListener("abort", onAbort); + this.#streams.delete(output); + resolve(); + }); + }); + // Readable.from waits for its async iterator to retire before close. Keep + // that whole lifetime owned, including a suspended positional read. + void this.#scope + .run(() => completion) + .catch((cause: unknown) => { + controller.abort(cause); + output.destroy( + cause instanceof Error + ? cause + : new Error("wxapkg stream ownership failed", { cause }), + ); + }); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted === true) onAbort(); + return output; + }); + } + + async close(): Promise { + this.#closing = true; + for (const [stream, controller] of this.#streams) { + const cause = new ArtifactReaderFailure( + "unavailable", + "wxapkg reader closed", + ); + controller.abort(cause); + stream.destroy(cause); + } + await this.#scope.close(); + await this.#source.close(); + this.#entries.clear(); + } + + provenance(): readonly [] { + return []; + } + + #assertOpen(signal?: AbortSignal): void { + assertArtifactReadActive(signal); + if (this.#closing) + throw new ArtifactReaderFailure("unavailable", "wxapkg reader is closed"); + } + + async #prepare(signal?: AbortSignal): Promise { + const { handle, initial } = await this.#source.read(signal); + const header = await readWxapkgHeader(handle, initial.size, signal); + if (header === undefined) + throw wxapkgFormatFailure("no complete wxapkg header"); + this.#header = { + kind: "wxapkg", + info: header.layout.status === "available" ? header.info : null, + index_length: + header.layout.status === "available" ? header.indexLength : null, + data_length: + header.layout.status === "available" ? header.dataLength : null, + file_count: + header.layout.status === "available" ? header.fileCount : null, + directory_status: header.layout.status, + }; + if (header.layout.status !== "available") return; + const { members } = await readWxapkgFileTable( + handle, + header, + initial.size, + signal, + ); + const budget = artifactExpansionBudgetBytes(initial.size); + let declaredBytes = 0n; + for (let index = 0; index < members.length; index++) { + if (index % 256 === 0) await setImmediate(); + this.#assertOpen(signal); + const member = members[index]!; + // Overlapping member ranges are valid; each declared path causes a read. + declaredBytes += BigInt(member.size); + if (declaredBytes > BigInt(budget)) + throw new ArtifactReaderFailure( + "limit", + `wxapkg members declare ${String(declaredBytes)} bytes, exceeding the ${String(budget)}-byte plaintext-work budget`, + { boundary: "memory" }, + ); + const adapterKey = `wxapkg-entry:${String(index)}`; + this.#entries.set(adapterKey, { + entry: { + path: member.path, + sourcePath: member.declaredPath, + kind: "file", + declaredSize: member.size, + compressedSize: null, + executable: false, + encrypted: false, + // Member offsets are absolute carrier positions. + byteOffset: member.offset, + declaredIntegrity: null, + unpacked: false, + limitations: [], + adapterKey, + formatMetadata: { + kind: "wxapkg-entry", + index, + source_path: member.declaredPath, + declared_offset: member.offset, + }, + }, + member, + }); + } + // Header, table and per-chunk member reads; a small-reader floor applies. + this.#admittedReadOperations = + 4 * + Math.max( + 1024, + members.length + Math.ceil(Number(declaredBytes) / CHUNK_BYTES), + ); + this.#remainingReadOperations = this.#admittedReadOperations; + await this.#source.read(signal); + } + + async *#memberChunks( + handle: FileHandle, + state: WxapkgEntryState, + signal: AbortSignal, + ): AsyncGenerator { + const { offset, size } = state.member; + let delivered = 0, + chunks = 0; + for (let position = offset; position < offset + size;) { + if (chunks++ % 64 === 0) await setImmediate(); + this.#assertOpen(signal); + this.#reserveMemberRead(); + const length = Math.min(CHUNK_BYTES, offset + size - position); + const chunk = await readArtifactFileBytes( + handle, + position, + length, + offset + size, + signal, + ); + position += chunk.length; + delivered += chunk.length; + yield chunk; + } + if (delivered !== size) + throw wxapkgFormatFailure("member ended before its declared size"); + await this.#source.read(signal); + } + + #reserveMemberRead(): void { + if (this.#remainingReadOperations <= 0) + throw artifactFixedLimitFailure( + "wxapkg directory-and-chunk I/O work", + this.#admittedReadOperations, + "operations", + ); + this.#remainingReadOperations--; + } +} diff --git a/src/artifacts/WxapkgStructures.ts b/src/artifacts/WxapkgStructures.ts new file mode 100644 index 0000000000..3437ead7a7 --- /dev/null +++ b/src/artifacts/WxapkgStructures.ts @@ -0,0 +1,214 @@ +import type { FileHandle } from "node:fs/promises"; +import { + ArtifactReaderFailure, + artifactFixedLimitFailure, +} from "./ArtifactReader.js"; +import { readArtifactFileBytes } from "./ArtifactFileRange.js"; +import { assertArtifactReadActive } from "./ArtifactFileOperation.js"; +import { setImmediate } from "node:timers/promises"; +import { + hasWxapkgEncryptedMagic, + readWxapkgHeaderFacts, + resolveWxapkgIndexLayouts, + selectWxapkgIndexLayout, + WXAPKG_MIN_ROW_BYTES, + type WxapkgIndexLayout, + type WxapkgIndexLayoutFailure, +} from "../domain/wxapkgFormat.js"; + +/** The member table is a flat name/offset/size row list. */ +const MAX_TABLE_BYTES = 16 * 1024 * 1024; +/** Encoded retained directory representation; paths are charged separately. */ +const DIRECTORY_BUDGET_BYTES = 64 * 1024 * 1024; +/** Member names are relative package paths; larger claims are malformed. */ +const MAX_MEMBER_NAME_BYTES = 64 * 1024; +/** Detecting prefix covers the fixed header, table head and encrypted magic. */ +const HEADER_PREFIX_BYTES = 64; + +const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +export const wxapkgFormatFailure = (detail: string): ArtifactReaderFailure => + new ArtifactReaderFailure("format", `wxapkg is malformed: ${detail}`); + +/** Reserve buffers, JS rows and escaped name projections before allocating. */ +class WxapkgDirectoryBudget { + #remaining = DIRECTORY_BUDGET_BYTES; + reserve(bytes: number): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) + throw artifactFixedLimitFailure( + "wxapkg directory representation", + DIRECTORY_BUDGET_BYTES, + "bytes", + ); + this.#remaining -= bytes; + } +} + +export interface WxapkgHeader { + readonly info: number; + readonly indexLength: number; + readonly dataLength: number; + readonly fileCount: number; + readonly layout: + | { readonly status: "encrypted" } + | { + readonly status: "available"; + readonly indexLayouts: readonly WxapkgIndexLayout[]; + }; +} + +export interface WxapkgMember { + readonly offset: number; + readonly size: number; + /** Package-relative path; declared root prefixes are translated here. */ + readonly path: string; + /** Declared member name as stored in the table. */ + readonly declaredPath: string; +} + +export interface WxapkgFileTable { + readonly members: readonly WxapkgMember[]; +} + +/** Decode header fields once; encrypted variants and absent signatures are distinct. */ +export const readWxapkgHeader = async ( + handle: FileHandle, + size: number, + signal?: AbortSignal, +): Promise => { + const prefix = await readArtifactFileBytes( + handle, + 0, + Math.min(size, HEADER_PREFIX_BYTES), + size, + signal, + ); + if (hasWxapkgEncryptedMagic(prefix)) + return { + info: 0, + indexLength: 0, + dataLength: 0, + fileCount: 0, + layout: { status: "encrypted" }, + }; + const facts = readWxapkgHeaderFacts(prefix); + if (facts === undefined) return undefined; + const indexes = resolveWxapkgIndexLayouts(facts, size); + if (!indexes.ok) throw wxapkgIndexLayoutFailure(indexes.error); + return { + ...facts, + layout: { status: "available", indexLayouts: indexes.value }, + }; +}; + +const wxapkgIndexLayoutFailure = ( + failure: WxapkgIndexLayoutFailure, +): ArtifactReaderFailure => + wxapkgFormatFailure( + failure._tag === "DataRegionExceedsFile" + ? "declared data region exceeds the file" + : "declared member table exceeds the file", + ); + +export const readWxapkgFileTable = async ( + handle: FileHandle, + header: WxapkgHeader, + size: number, + signal?: AbortSignal, +): Promise => { + const layout = header.layout; + if (layout.status !== "available") + throw new ArtifactReaderFailure( + "unavailable", + "wxapkg directory is encrypted", + ); + const indexLayouts = layout.indexLayouts; + let readBytes = 0; + let tableStart: number | undefined; + for (const indexLayout of indexLayouts) { + if (tableStart === undefined) tableStart = indexLayout.tableStart; + if (indexLayout.tableBytes > readBytes) readBytes = indexLayout.tableBytes; + } + if (tableStart === undefined) + throw wxapkgFormatFailure("declared member table exceeds the file"); + if (readBytes > MAX_TABLE_BYTES) + throw artifactFixedLimitFailure( + "wxapkg member table", + MAX_TABLE_BYTES, + "bytes", + ); + const budget = new WxapkgDirectoryBudget(); + budget.reserve(readBytes); + const table = await readArtifactFileBytes( + handle, + tableStart, + readBytes, + size, + signal, + ); + // Loose lower bound already checked in facts; precise rows are checked here. + if (table.length < header.fileCount * WXAPKG_MIN_ROW_BYTES) + throw wxapkgFormatFailure("member table is truncated"); + const members: WxapkgMember[] = []; + let cursor = 0; + for (let index = 0; index < header.fileCount; index++) { + if (index % 256 === 0) await setImmediate(); + assertArtifactReadActive(signal); + if (cursor + 4 > table.length) + throw wxapkgFormatFailure("member row is truncated"); + const nameLength = table.readUInt32BE(cursor); + cursor += 4; + if ( + nameLength === 0 || + nameLength > MAX_MEMBER_NAME_BYTES || + cursor + nameLength + 8 > table.length + ) + throw wxapkgFormatFailure("member row exceeds the table"); + // Row storage plus the reader's owned entries/map; charge before decode. + budget.reserve(512 + nameLength * 24); + let declaredPath: string; + try { + declaredPath = UTF8.decode(table.subarray(cursor, cursor + nameLength)); + } catch (cause: unknown) { + throw new ArtifactReaderFailure( + "format", + "wxapkg member path is not valid UTF-8", + { + cause, + }, + ); + } + cursor += nameLength; + const offset = table.readUInt32BE(cursor); + const sizeMember = table.readUInt32BE(cursor + 4); + cursor += 8; + // Producer names are root-prefixed; translate to package-relative once. + const path = declaredPath.startsWith("/") + ? declaredPath.slice(1) + : declaredPath; + if (path.length === 0) + throw wxapkgFormatFailure("member name has no package-relative path"); + members.push({ offset, size: sizeMember, path, declaredPath }); + } + const candidates = indexLayouts.filter((index) => index.tableBytes >= cursor); + if (candidates.length === 0) + throw wxapkgFormatFailure("member table is truncated"); + // Rows and member ranges choose the convention. EOF is not a signal: four + // trailing bytes can make the other convention end on the carrier. + const index = selectWxapkgIndexLayout(candidates, cursor, members); + if (index === undefined) { + const outside = members.find((member) => { + const extentEnd = member.offset + member.size; + return candidates.every( + (candidate) => + member.offset < candidate.dataStart || + extentEnd > candidate.dataEnd || + extentEnd < member.offset, + ); + }); + throw wxapkgFormatFailure( + `member exceeds the data region: ${outside?.path ?? ""}`, + ); + } + return { members }; +}; diff --git a/src/artifacts/artifactFormats.test.ts b/src/artifacts/artifactFormats.test.ts index da351dc0c8..acbd1fe124 100644 --- a/src/artifacts/artifactFormats.test.ts +++ b/src/artifacts/artifactFormats.test.ts @@ -30,6 +30,7 @@ type FormatsWithReaders = | "ines" | "godot-pck" | "unityfs" + | "wxapkg" | "uimage" | "mach-o-universal" | "directory" diff --git a/src/artifacts/artifactFormats.ts b/src/artifacts/artifactFormats.ts index aeb7dc8bff..92c343e202 100644 --- a/src/artifacts/artifactFormats.ts +++ b/src/artifacts/artifactFormats.ts @@ -40,6 +40,7 @@ import { PyInstallerArtifactReader } from "./PyInstallerArtifactReader.js"; import { Uf2ArtifactReader } from "./Uf2ArtifactReader.js"; import { UImageArtifactReader } from "./UImageArtifactReader.js"; import { UnityFsArtifactReader } from "./UnityFsArtifactReader.js"; +import { WxapkgArtifactReader } from "./WxapkgArtifactReader.js"; import { ZipArtifactReader } from "./ZipArtifactReader.js"; /** How inventory describes a format when no reader was constructed. */ @@ -286,6 +287,16 @@ const NON_ZIP_FORMATS = { createReader: (path, options) => new UnityFsArtifactReader(path, sourceOf(options)), }), + wxapkg: container("wxapkg", { + roleContainer: true, + retainClassifiedSource: true, + extractable: true, + absentInventory: "unavailable", + readerLimitation: + "wxapkg inventory establishes packaged member names and stored bytes; wxml/wxss sources, compiled bundles and runtime behavior are not reconstructed. V1MMWX-encrypted packages are unsupported.", + createReader: (path, options) => + new WxapkgArtifactReader(path, sourceOf(options)), + }), uimage: container("uimage", { roleContainer: true, retainClassifiedSource: true, diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index d51a52c083..16e9745b75 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -215,6 +215,15 @@ export const materializeArtifactInventoryInScope = async ( input.selectedPath ?? input.inputPath, `UnityFS directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`, ); + if ( + containerMetadata?.kind === "wxapkg" && + containerMetadata.directory_status !== "available" + ) + throw new AnalysisUnsupportedTargetError( + "extract_artifact", + input.selectedPath ?? input.inputPath, + `wxapkg directory is ${containerMetadata.directory_status}; its complete contents cannot be extracted`, + ); const selectedOccurrences = snapshot.occurrences.filter( (occurrence) => (occurrence.entry_kind === "file" || occurrence.entry_kind === "slice") && diff --git a/src/artifacts/hostExecutableMarking.test.ts b/src/artifacts/hostExecutableMarking.test.ts index 9f97186d24..ad1b39858c 100644 --- a/src/artifacts/hostExecutableMarking.test.ts +++ b/src/artifacts/hostExecutableMarking.test.ts @@ -2,8 +2,21 @@ import { describe, expect, it } from "vitest"; import { hostExecutableMarking } from "./hostExecutableMarking.js"; -const file = (mode: number) => ({ mode, isFile: () => true }); -const directory = (mode: number) => ({ mode, isFile: () => false }); +const file = (mode: number) => ({ + mode, + isFile: () => true, + isSymbolicLink: () => false, +}); +const directory = (mode: number) => ({ + mode, + isFile: () => false, + isSymbolicLink: () => false, +}); +const symlink = (mode: number) => ({ + mode, + isFile: () => false, + isSymbolicLink: () => true, +}); describe("hostExecutableMarking", () => { it("marks Windows launchable files by extension because NTFS has no execute bit", () => { @@ -48,4 +61,15 @@ describe("hostExecutableMarking", () => { hostExecutableMarking("/scratch/whoami.exe", file(0o644), "darwin"), ).toBe(false); }); + + it("never marks a symlink, whose target decides what runs", () => { + for (const [path, platform] of [ + ["/scratch/current", "linux"], + ["/scratch/current", "darwin"], + ["C:\\scratch\\tool.exe", "win32"], + ] as const) + expect(hostExecutableMarking(path, symlink(0o120777), platform)).toBe( + false, + ); + }); }); diff --git a/src/artifacts/hostExecutableMarking.ts b/src/artifacts/hostExecutableMarking.ts index 5549d099f6..14413c5965 100644 --- a/src/artifacts/hostExecutableMarking.ts +++ b/src/artifacts/hostExecutableMarking.ts @@ -9,16 +9,20 @@ const windowsLaunchableExtensions = new Set([".bat", ".cmd", ".com", ".exe"]); * * POSIX hosts mark executability with permission bits. Windows has no execute * bit (Node reports file modes from the read-only attribute alone), so it marks - * launchable files by extension instead. + * launchable files by extension instead. A symlink carries no marking on any + * host: running it runs its target, and its own permission bits do not + * control that (Linux reports 0777 for every symlink). */ export const hostExecutableMarking = ( path: string, - metadata: Pick, + metadata: Pick, platform: NodeJS.Platform = process.platform, ): boolean => - platform === "win32" - ? metadata.isFile() && - windowsLaunchableExtensions.has(extname(path).toLowerCase()) - : typeof metadata.mode === "bigint" - ? (metadata.mode & 0o111n) !== 0n - : (metadata.mode & 0o111) !== 0; + metadata.isSymbolicLink() + ? false + : platform === "win32" + ? metadata.isFile() && + windowsLaunchableExtensions.has(extname(path).toLowerCase()) + : typeof metadata.mode === "bigint" + ? (metadata.mode & 0o111n) !== 0n + : (metadata.mode & 0o111) !== 0; diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 3075f65f91..f23e26223c 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -14,6 +14,10 @@ import { resolveOccurrenceParents, } from "./ArtifactGraphConstruction.js"; import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js"; +import { createAsarHeaderSignatureProbe } from "../AsarHeader.js"; +import { ArtifactReaderFailure } from "../ArtifactReader.js"; +import { translateArtifactFailure } from "../ArtifactOperationFailure.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { artifactIdForContent } from "../../domain/artifactIdentity.js"; import { pe } from "../../domain/binaryTarget.fixture.js"; import type { ArtifactEntry } from "../ArtifactReader.js"; @@ -26,6 +30,7 @@ import { } from "../../domain/firmware/uf2.fixture.js"; import { classifyArtifactBytesAtPath } from "../../../tests/fixtures/artifactClassification.js"; +import { asarHeaderBytes } from "../../../tests/fixtures/asarHeader.js"; import { ESP32C3_APP_SEGMENTS, esp8266Image, @@ -73,6 +78,42 @@ it("classifies original DOS header padding as an executable", () => { }); }); +it("projects the parsed ASAR header size when admission rejects an archive", () => { + const bytes = asarHeaderBytes("{}", { padding: Buffer.alloc(8, 0x20) }); + const headerBytes = bytes.readUInt32LE(4); + const probe = createAsarHeaderSignatureProbe({ + maximumBytes: headerBytes - 1, + source: "setting", + }); + probe.write(bytes); + const asarHeader = probe.finish(bytes.length); + expect(asarHeader).toMatchObject({ + state: "limit", + extents: { headerBytes }, + }); + + let failure: unknown; + try { + classifyArtifactContent( + "app.asar", + { prefix: bytes, asarHeader }, + bytes.length, + ); + } catch (cause) { + failure = cause; + } + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + if (!(failure instanceof ArtifactReaderFailure)) + throw new Error("ASAR admission failure was not classified"); + + const projected = projectAnalysisError( + translateArtifactFailure("inspect_artifact", failure), + ); + expect(projected.details?.limit).toMatchObject({ + required_value: headerBytes, + }); +}); + const entry = (path: string, kind: ArtifactEntry["kind"]): ArtifactEntry => ({ path, kind, diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 75614bcb2c..27561c6772 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -2,6 +2,10 @@ import { artifactFormatDescriptor } from "../artifactFormats.js"; import { hasGodotPckHeader } from "../../domain/godotPckFormat.js"; import { hasUImageMagic } from "../../domain/uimageFormat.js"; import { hasUnityFsHeader } from "../../domain/unityfsFormat.js"; +import { + hasWxapkgEncryptedMagic, + hasWxapkgHeader, +} from "../../domain/wxapkgFormat.js"; import { detectHexRecordEncoding } from "../../domain/firmware/hexRecords.js"; import { recognizeEspImage } from "../../domain/firmware/espImage.js"; import { hasUf2Block } from "../../domain/firmware/uf2.js"; @@ -296,7 +300,8 @@ export const rootOccurrenceFor = ( executable: metadata.executable, encrypted: (metadata.formatMetadata?.kind === "godot-pck" || - metadata.formatMetadata?.kind === "unityfs") && + metadata.formatMetadata?.kind === "unityfs" || + metadata.formatMetadata?.kind === "wxapkg") && metadata.formatMetadata.directory_status === "encrypted", hash_status: "verified", source_location: null, @@ -557,7 +562,10 @@ export const classifyArtifactContent = ( asarHeader.state !== "rejected" ) { if (asarHeader.state === "limit") - throw asarHeaderAdmissionFailure(asarHeader.admission); + throw asarHeaderAdmissionFailure( + asarHeader.admission, + asarHeader.extents?.headerBytes, + ); if (asarHeader.state === "matched") role = { kind: "container", format: "asar" }; else role = artifactRoleForFormat(path, contentFormat); @@ -604,6 +612,8 @@ export const classifyArtifactBytes = ( if (hasZipSignature(prefix)) return "zip"; if (hasGodotPckHeader(prefix)) return "godot-pck"; if (hasUnityFsHeader(prefix)) return "unityfs"; + if (hasWxapkgEncryptedMagic(prefix) || hasWxapkgHeader(prefix)) + return "wxapkg"; if (hasUImageMagic(prefix)) return "uimage"; const hexRecords = detectHexRecordEncoding(prefix); if (hexRecords !== undefined) return hexRecords; diff --git a/src/artifacts/nodeSea/NodeSeaBlob.ts b/src/artifacts/nodeSea/NodeSeaBlob.ts index 4c8cd2d0ca..e63da49fd9 100644 --- a/src/artifacts/nodeSea/NodeSeaBlob.ts +++ b/src/artifacts/nodeSea/NodeSeaBlob.ts @@ -43,6 +43,8 @@ export const NODE_SEA_ELF_DESCRIPTOR_TRUNCATED = "Node SEA ELF note descriptor is truncated"; export const NODE_SEA_ELF_TABLE_TRUNCATED = "Node SEA ELF note table is truncated"; +export const NODE_SEA_ELF_NOTES_REPEATED = + "Node SEA ELF note tables declare more notes than the file holds"; export const NODE_SEA_MACHO_COMMANDS_TRUNCATED = "Node SEA Mach-O load commands are truncated"; export const NODE_SEA_MACHO_SEGMENT_TRUNCATED = diff --git a/src/artifacts/nodeSea/NodeSeaLocation.ts b/src/artifacts/nodeSea/NodeSeaLocation.ts index 45095003f3..0fcddec37b 100644 --- a/src/artifacts/nodeSea/NodeSeaLocation.ts +++ b/src/artifacts/nodeSea/NodeSeaLocation.ts @@ -33,6 +33,7 @@ import { NODE_SEA_BLOB_TOO_LARGE, NODE_SEA_DUPLICATE_BLOB, NODE_SEA_ELF_DESCRIPTOR_TRUNCATED, + NODE_SEA_ELF_NOTES_REPEATED, NODE_SEA_ELF_TABLE_TRUNCATED, NODE_SEA_MACHO_COMMANDS_TRUNCATED, NODE_SEA_MACHO_SEGMENT_TRUNCATED, @@ -358,6 +359,10 @@ const locateElfNotes = async ( const minimumSection = elf64 ? 64 : 40; const found: BlobRange[] = []; let opaque = false; + // Every note header occupies at least 12 bytes, so disjoint tables hold at + // most one note per 12 file bytes. Headers that point at the same bytes + // would otherwise rescan them once per declaration. + const scan: NoteScan = { little, notes: Math.floor(carrier.fileSize / 12) }; const programs = await readElfTables(carrier, { offset: phoff, count: phnum, @@ -392,7 +397,7 @@ const locateElfNotes = async ( const scanned = await scanElfNoteTable( carrier, note, - little, + scan, found.length > 0, ); if (scanned === undefined) opaque = true; @@ -440,7 +445,7 @@ const locateElfNotes = async ( const scanned = await scanElfNoteTable( carrier, note, - little, + scan, found.length > 0, ); if (scanned === undefined) opaque = true; @@ -485,6 +490,12 @@ const readElfTables = async ( return entries; }; +/** One carrier's note byte order and the note headers it may still read. */ +interface NoteScan { + readonly little: boolean; + notes: number; +} + const scanElfNoteTable = async ( carrier: Carrier, note: { @@ -492,7 +503,7 @@ const scanElfNoteTable = async ( readonly length: number | undefined; readonly align: number | undefined; }, - little: boolean, + scan: NoteScan, alreadyFound: boolean, ): Promise => { if ( @@ -507,10 +518,18 @@ const scanElfNoteTable = async ( let cursor = 0; while (cursor + 12 <= note.length) { abortIfNeeded(carrier.signal); + // Past the file's note capacity the tables overlap: an unconfirmed + // carrier stays unrecognized and a confirmed one fails closed. + if (scan.notes === 0) { + if (alreadyFound || found.length > 0) + throw formatFailure(NODE_SEA_ELF_NOTES_REPEATED); + return undefined; + } + scan.notes -= 1; const header = await readExact(carrier, note.offset + cursor, 12); if (header === undefined) return failNoteTable(alreadyFound || found.length > 0); - const parsedHeader = readElfNoteHeader(header, little); + const parsedHeader = readElfNoteHeader(header, scan.little); if (!parsedHeader.ok) return failNoteTable(alreadyFound || found.length > 0); const { namesz, descsz } = parsedHeader.value; diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts index ad3371d3f7..ec20830575 100644 --- a/src/artifacts/readStableArtifact.ts +++ b/src/artifacts/readStableArtifact.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { constants, type BigIntStats } from "node:fs"; -import { lstat, open, type FileHandle } from "node:fs/promises"; +import { lstat, open, realpath, type FileHandle } from "node:fs/promises"; import { sameRegularFileState } from "../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import type { ArtifactResourceScope } from "./ArtifactResourceScope.js"; @@ -38,11 +38,29 @@ export const completedStableArtifact = ( export interface StableArtifactFileSystem { lstat(path: string): Promise; open(path: string, flags: number): Promise; + realpath?(path: string): Promise; } const FILE_SYSTEM: StableArtifactFileSystem = { lstat: (path) => lstat(path, { bigint: true }), open, + realpath, +}; + +/** Suggest a retry only when the rejected link currently resolves to a regular file. */ +const resolvedRegularFile = async ( + path: string, + fileSystem: StableArtifactFileSystem, +): Promise => { + try { + const resolved = await fileSystem.realpath?.(path); + if (resolved === undefined) return undefined; + const target = await fileSystem.lstat(resolved); + return target.isFile() && !target.isSymbolicLink() ? resolved : undefined; + } catch { + // A missing or unreadable target changes only the recovery hint. + return undefined; + } }; const afterValidation = async ( @@ -77,6 +95,9 @@ export const readStableArtifact = async ( throw new ArtifactReaderFailure( "path", `Expected a regular file without a symlink: ${path}`, + before.isSymbolicLink() + ? { resolvedPath: await resolvedRegularFile(path, fileSystem) } + : {}, ); const maximumSize = BigInt(maximumBytes); if (before.size > maximumSize) diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index e3e6621e41..f328862c7a 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -1,5 +1,6 @@ import { err, ok, type Result } from "../domain/result.js"; import { mediaTypeEssence } from "../domain/analysisView/recordedMediaType.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; import { admitRecords, isHttpUrl, @@ -57,18 +58,22 @@ export const exclusionReasonForUrl = ( ? "disallowed_origin" : "unsupported_url"; -/** Preserve omitted and invalid protocol offsets as unknown, never invented coordinates. */ +/** + * Preserve omitted and invalid protocol offsets as unknown, never invented + * coordinates. Columns follow the shared CDP resource-column policy, which + * keeps a negative column Chrome reports. + */ export const cdpSourceCoordinates = (frame: UnknownRecord | undefined) => { - const valid = (value: unknown): value is number => + const validLine = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; const line = frame?.lineNumber; - const column = frame?.columnNumber; + const column = cdpResourceColumnSchema.safeParse(frame?.columnNumber); return { - line: valid(line) ? line : null, - column: valid(column) ? column : null, + line: validLine(line) ? line : null, + column: column.success ? column.data : null, invalid: - (line !== undefined && !valid(line)) || - (column !== undefined && !valid(column)), + (line !== undefined && !validLine(line)) || + (frame?.columnNumber !== undefined && !column.success), }; }; diff --git a/src/browser/CdpNetworkEventFacts.ts b/src/browser/CdpNetworkEventFacts.ts index 54f3f26632..ae7fa548d3 100644 --- a/src/browser/CdpNetworkEventFacts.ts +++ b/src/browser/CdpNetworkEventFacts.ts @@ -11,6 +11,7 @@ import { recordValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); /** Active runtime admission remains stricter than passive request observations. */ @@ -25,7 +26,7 @@ export const runtimeRequestSchema = z.object({ stack: z.unknown().optional(), url: z.string().optional(), lineNumber: integer.optional(), - columnNumber: integer.optional(), + columnNumber: cdpResourceColumnSchema.optional(), }) .passthrough(), }); diff --git a/src/browser/CdpScriptContextFacts.ts b/src/browser/CdpScriptContextFacts.ts index 0ba1ae0cf9..a95d1db86a 100644 --- a/src/browser/CdpScriptContextFacts.ts +++ b/src/browser/CdpScriptContextFacts.ts @@ -4,6 +4,7 @@ import { numberValue, recordValue, } from "./CdpCaptureValues.js"; +import { cdpResourceColumnSchema } from "../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const contextAuxDataSchema = z @@ -31,9 +32,9 @@ export const runtimeScriptParsedSchema = scriptDeclaration.extend({ scriptId: z.string().min(1), executionContextId: integer, startLine: integer, - startColumn: integer, + startColumn: cdpResourceColumnSchema, endLine: integer, - endColumn: integer, + endColumn: cdpResourceColumnSchema, hash: z.string().optional(), sourceMapURL: z.string().optional(), hasSourceURL: z.boolean().optional(), diff --git a/src/browser/ChromiumSandbox.ts b/src/browser/ChromiumSandbox.ts new file mode 100644 index 0000000000..e5949c11ec --- /dev/null +++ b/src/browser/ChromiumSandbox.ts @@ -0,0 +1,32 @@ +/** Host opt-out for Chromium launches where the host cannot provide its sandbox. */ +export const CHROMIUM_NO_SANDBOX_VARIABLE = "REA_BROWSER_NO_SANDBOX"; + +/** + * Decide whether REA-launched Chromium keeps its sandbox. + * + * Playwright disables the Chromium sandbox unless the launch asks for it, so + * every launch passes this decision explicitly. Termux's Chromium has no usable + * sandbox; other hosts opt out only through {@link CHROMIUM_NO_SANDBOX_VARIABLE}. + */ +export const chromiumSandboxEnabled = ( + environment: Readonly>, + platform: NodeJS.Platform = process.platform, +): boolean => + platform !== "android" && + environment[CHROMIUM_NO_SANDBOX_VARIABLE] !== "true"; + +const LAUNCH_REMEDIATION = + "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying."; + +/** + * Remediation for a Chromium launch that failed before the browser connected. + * + * Chromium reports an unusable sandbox through several unrelated messages (no + * namespace support, a misconfigured setuid helper, a refused namespace move, + * a root user), so REA does not classify the startup log. A sandboxed launch + * adds the opt-out; the retained diagnostics show whether it applies. + */ +export const chromiumLaunchRemediation = (sandbox: boolean): string => + sandbox + ? `${LAUNCH_REMEDIATION} If the startup diagnostics show that Chromium could not start its sandbox on this host, enable the host's sandbox support or set ${CHROMIUM_NO_SANDBOX_VARIABLE}=true to launch it without the sandbox.` + : LAUNCH_REMEDIATION; diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 3af1166b3f..b36b3a7462 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -172,7 +172,8 @@ it("keeps Termux execution support when launching Chromium on Android", async () diagnostics: { startup_error: "launch boundary reached" }, }); - expect(launchOptions?.args).toEqual(["--no-sandbox", "--single-process"]); + expect(launchOptions?.chromiumSandbox).toBe(false); + expect(launchOptions?.args).toEqual(["--single-process"]); expect(launchOptions?.env).toEqual({ LANG: "selected-language", LD_PRELOAD: "/data/data/com.termux/files/usr/lib/libtermux-exec.so", @@ -180,6 +181,51 @@ it("keeps Termux execution support when launching Chromium on Android", async () }); }); +const launchScenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: process.execPath }, + start_url: { url: "https://example.test" }, + actions: [{ step_id: "settle", action: "wait_for_timeout", duration_ms: 1 }], +}); + +it.each([ + { environment: {}, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "false" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "1" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "true" }, sandbox: false }, +])( + "launches Chromium with sandbox $sandbox for $environment", + async ({ environment, sandbox }) => { + Object.defineProperty(process, "platform", { value: "linux" }); + let launchOptions: Parameters< + typeof import("playwright-core").chromium.launchPersistentContext + >[1]; + + await expect( + openPlaywrightScenarioBrowser(launchScenario, environment, { + launcher: { + connectOverCDP: async () => { + throw new Error("Unexpected connection"); + }, + launchPersistentContext: async (_profile, options) => { + launchOptions = options; + throw new Error("launch boundary reached"); + }, + }, + }), + ).rejects.toMatchObject({ + diagnostics: { startup_error: "launch boundary reached" }, + // Chromium's startup log, not REA, says whether the sandbox caused it. + remediationAction: sandbox + ? expect.stringContaining("REA_BROWSER_NO_SANDBOX=true") + : "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying.", + }); + + expect(launchOptions?.chromiumSandbox).toBe(sandbox); + expect(launchOptions?.args).toEqual([]); + expect(launchOptions?.env).toEqual({}); + }, +); + it("waits for bounded event finalization and owned browser teardown", async () => { vi.useFakeTimers(); try { diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index 3df1e7b87b..9b525daffe 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -1,4 +1,8 @@ import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { + chromiumLaunchRemediation, + chromiumSandboxEnabled, +} from "./ChromiumSandbox.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { @@ -869,6 +873,7 @@ export const openPlaywrightScenarioBrowser = async ( let context: BrowserContext; const executablePath = scenario.browser.executable_path; const headless = scenario.browser.headless; + const chromiumSandbox = chromiumSandboxEnabled(environment); let launchStarted = false; try { await chmod(profilePath, 0o700); @@ -894,13 +899,11 @@ export const openPlaywrightScenarioBrowser = async ( handleSIGINT: false, handleSIGTERM: false, timeout: commandTimeoutMs, + chromiumSandbox, // Termux's Chromium cannot create its renderer process through Android's // executable boundary. Single-process mode keeps the browser and page in // the Termux-launched process so scenario navigation remains available. - args: - process.platform === "android" - ? ["--no-sandbox", "--single-process"] - : [], + args: process.platform === "android" ? ["--single-process"] : [], }), commandTimeoutMs, "launch", @@ -923,8 +926,7 @@ export const openPlaywrightScenarioBrowser = async ( { cause, userMessage: "The selected browser could not start.", - remediationAction: - "Review the selected executable and startup diagnostics, then correct the launch configuration before retrying.", + remediationAction: chromiumLaunchRemediation(chromiumSandbox), diagnostics: { executable_path: executablePath, startup_error: diff --git a/src/browser/PlaywrightScenarioEvents.test.ts b/src/browser/PlaywrightScenarioEvents.test.ts index a139c96f39..659b6e1498 100644 --- a/src/browser/PlaywrightScenarioEvents.test.ts +++ b/src/browser/PlaywrightScenarioEvents.test.ts @@ -283,6 +283,64 @@ describe("PlaywrightScenarioEvents", () => { }); }); +it("retains network response events with HTTP status codes above 599 without error", () => { + const contextListeners = new Map void>(); + const context = { + on: (name: string, listener: (value: unknown) => void) => { + contextListeners.set(name, listener); + }, + off: () => {}, + } as unknown as BrowserContext; + const page = { + url: () => "https://app.example.test/", + context: () => context, + on: () => {}, + } as unknown as Page; + const scenario = browserScenarioSchema.parse({ + browser: { mode: "launch", executable_path: "/opt/chromium" }, + start_url: { url: "https://app.example.test/" }, + actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }], + capture: { events: ["network"] }, + }); + const secrets = resolveScenarioSecrets(scenario, {}); + const events = new PlaywrightScenarioEvents({ + page, + context, + enabled: new Set(["network"]), + secrets, + }); + const request = { + url: () => "https://app.example.test/api/check", + method: () => "GET", + resourceType: () => "xhr", + postDataBuffer: () => null, + redirectedFrom: () => null, + failure: () => null, + headers: () => ({}), + headersArray: async () => [], + frame: () => ({ page: () => page }), + } as unknown as Request; + const response = { + request: () => request, + url: () => request.url(), + status: () => 999, + headers: () => ({ "content-type": "text/plain" }), + headersArray: async () => [{ name: "content-type", value: "text/plain" }], + body: async () => Buffer.from("denied"), + } as unknown as Response; + + contextListeners.get("request")?.(request); + expect(() => contextListeners.get("response")?.(response)).not.toThrow(); + const items = events.result().items; + const responseEvent = items.find((e) => e.kind === "response"); + expect(responseEvent).toBeDefined(); + expect(responseEvent).toMatchObject({ + kind: "response", + status: 999, + url: { url: "https://app.example.test/api/check" }, + }); +}); + it("retains ordinary event volumes and reports event-count exhaustion", () => { const listeners = new Map void)[]>(); const page = { diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index 009fe9e901..a0b469160e 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -1,6 +1,7 @@ import type { WebArtifactAcquisition as Acquisition } from "../../domain/webArtifactAcquisition.js"; import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, artifactFilePrimaryFacts, @@ -250,9 +251,16 @@ const selectedFileError = ( context: WebArtifactReadContext, ): AnalysisError | undefined => { const selected = (message: string) => - new AnalysisInputError(context.operation, { cause }, [ - { path: context.field, reason: "invalid_value", message }, - ]); + new AnalysisInputError( + context.operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [{ path: context.field, reason: "invalid_value", message }], + ); if ( origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure diff --git a/src/browser/execution/CdpRuntimeProtocol.ts b/src/browser/execution/CdpRuntimeProtocol.ts index a9fdb5bff2..2b90e09d8c 100644 --- a/src/browser/execution/CdpRuntimeProtocol.ts +++ b/src/browser/execution/CdpRuntimeProtocol.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { cdpResourceColumnSchema } from "../../domain/cdpObservationSchemas.js"; const integer = z.int().min(0); const range = z.object({ @@ -30,7 +31,7 @@ export const runtimeStackSchema = z.object({ scriptId: z.string(), url: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, functionName: z.string(), }), ), @@ -49,7 +50,7 @@ export const runtimeListenersSchema = z.object({ once: z.boolean(), scriptId: z.string(), lineNumber: integer, - columnNumber: integer, + columnNumber: cdpResourceColumnSchema, }), ), }); diff --git a/src/browser/history/CaptureFailures.test.ts b/src/browser/history/CaptureFailures.test.ts index 260c630a1e..72eb16f407 100644 --- a/src/browser/history/CaptureFailures.test.ts +++ b/src/browser/history/CaptureFailures.test.ts @@ -12,6 +12,38 @@ import { NODE_CAPTURE_PROCESS_MEMORY_LIMIT_BYTES } from "./CaptureRelease.js"; import { redactExplicitFailure } from "../../domain/explicitSensitiveFailure.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +it.each([ + { phase: "capture-read", field: ["capture_path"] }, + { + phase: "descriptor-read", + field: ["protobuf", "schema", "descriptor_set_path"], + }, +] as const)("offers the resolved-file retry for $phase", ({ phase, field }) => { + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: "/selected/capture.har", + format: "har", + protobuf: { + schema: { + descriptor_set_path: "/selected/schema.pb", + message_type: "capture.Message", + }, + }, + }); + const resolvedPath = "/resolved/regular-file"; + const cause = new ArtifactReaderFailure("path", "Selected a symlink", { + resolvedPath, + }); + const projected = projectAnalysisError( + historicalCaptureFailure(input, cause, phase), + ); + expect(projected).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: field }] }, + remediation: { action: expect.stringContaining(resolvedPath) }, + }); + expect(projected).not.toHaveProperty("recovery"); +}); + it.each([ { label: "POSIX SIGABRT", exitCode: null, signal: "SIGABRT" }, { label: "Windows exit 134", exitCode: 134, signal: null }, diff --git a/src/browser/history/CaptureFailures.ts b/src/browser/history/CaptureFailures.ts index 0b51e4780d..cd02548a55 100644 --- a/src/browser/history/CaptureFailures.ts +++ b/src/browser/history/CaptureFailures.ts @@ -1,6 +1,7 @@ import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { bindArtifactFileCleanup, @@ -168,26 +169,30 @@ export const historicalCaptureFailure = ( cause.reason === "format" || cause.reason === "path") ) - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: inputPath, - reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", - message: - cause.reason === "limit" - ? `${cause.message}. Select a ${sourceLabel} within the ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget. This release has no runtime override. capture_timeout_ms does not raise it.` - : cause.message, - ...(cause.reason === "limit" - ? { - expected: { - [descriptorRead - ? "maximum_descriptor_set_bytes" - : "maximum_capture_bytes"]: - WEB_NETWORK_CAPTURE_LIMITS.inputBytes, - }, - } - : {}), - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: inputPath, + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: + cause.reason === "limit" + ? `${cause.message}. Select a ${sourceLabel} within the ${String(WEB_NETWORK_CAPTURE_LIMITS.inputBytes)}-byte input budget. This release has no runtime override. capture_timeout_ms does not raise it.` + : cause.message, + ...(cause.reason === "limit" + ? { + expected: { + [descriptorRead + ? "maximum_descriptor_set_bytes" + : "maximum_capture_bytes"]: + WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + } + : {}), + }, + ], + ); if ( phase !== "decoder" && cause instanceof Error && diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 6e58f32367..19e7b68aa3 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -805,12 +805,46 @@ it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])( }, ); +it.each(["[2620:0:860:ed1a::1]", "[::1]"])( + "retains Chromium DevTools bracketed IPv6 spelling in reported Evidence: %s", + (address) => { + const fixture = historicalHar(); + const first = fixture.log.entries[0]; + if (first === undefined) throw new Error("fixture missing"); + const input = { + log: { + ...fixture.log, + creator: { name: "WebInspector", version: "537.36" }, + entries: [ + { ...first, serverIPAddress: address }, + structuredClone(first), + ], + }, + }; + const decoded = decodeHarCapture(JSON.stringify(input), []); + expect(decoded.records[0]).toMatchObject({ + reported: { serverIPAddress: address }, + }); + expect(decoded.records[0]?.limitations).toContain( + "This capture declares Chromium DevTools (WebInspector 537.36) and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + ); + expect( + decoded.records[1]?.limitations.some((limitation) => + limitation.includes("bracketed IPv6 serverIPAddress"), + ), + ).toBe(false); + }, +); + it.each([ { name: "other", version: "1.58.2", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.1", address: "[2001:db8::1]" }, { name: "Playwright", version: "1.58.2", address: "[192.0.2.1]" }, { name: "Playwright", version: "1.58.2", address: "[2001:db8::1]:443" }, { name: "Playwright", version: "1.58.2", address: "[fe80::1%eth0]" }, + { name: "WebInspector", version: "605.1.15", address: "[2001:db8::1]" }, + { name: "WebInspector", version: "537.36", address: "[192.0.2.1]" }, + { name: "WebInspector", version: "537.36", address: "[2001:db8::1]:443" }, ])( "rejects unrecognized bracketed HAR address input: $name $version $address", ({ name, version, address }) => { diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 91c67f9efe..4353e9b6de 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -33,9 +33,14 @@ export const decodeHarCapture = ( ): DecodedCapture => { const raw = parseCaptureJson(text, "HAR"); const validation = projectHar(raw, [], "validation"); - const adaptedPlaywrightEntries = new Set( - createHarValidator()(validation.reported), - ); + const ipv6Adaptation = createHarValidator()(validation.reported); + const adaptedIpv6Entries = new Set(ipv6Adaptation?.entries); + const ipv6Limitations = + ipv6Adaptation === null + ? [] + : [ + `This capture declares ${ipv6Adaptation.producer} and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.`, + ]; if ( !captureObject(raw) || !captureObject(raw.log) || @@ -76,11 +81,7 @@ export const decodeHarCapture = ( ...projection, limitations: [ "HAR text without an encoding declaration is producer-decoded Unicode. Unsupported declared body encodings remain opaque; original body bytes remain unknown unless content explicitly uses valid base64. Declared sizes are preserved independently of retained bytes. mitmproxy 12.2.3 SaveHar may report null optional postData.text; that null is retained and omitted only from the upstream validation copy.", - ...(adaptedPlaywrightEntries.has(ordinal) - ? [ - "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", - ] - : []), + ...(adaptedIpv6Entries.has(ordinal) ? ipv6Limitations : []), ...(recordsExcluded ? [ "This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.", diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index f50d91d392..43d5422a0d 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -30,10 +30,28 @@ const timingsSchema = { }, }; +// Observed producers that copy Chromium's bracketed IPv6 remote address into +// serverIPAddress. Chromium DevTools declares WebInspector with the frozen +// AppleWebKit version from its user agent. +const BRACKETED_IPV6_PRODUCERS = [ + { name: "Playwright", version: "1.58.2", label: "Playwright 1.58.2" }, + { + name: "WebInspector", + version: "537.36", + label: "Chromium DevTools (WebInspector 537.36)", + }, +] as const; + +/** Entries whose bracketed IPv6 address was adapted, and the declared producer. */ +interface BracketedIpv6Adaptation { + readonly producer: string; + readonly entries: readonly number[]; +} + /** Validate with pinned HAR 1.2 draft-06 schemas and the owned schema corrections. */ export const createHarValidator = (): (( value: unknown, -) => readonly number[]) => { +) => BracketedIpv6Adaptation | null) => { const require = createRequire(import.meta.url); const schemas = z .record(z.string(), z.record(z.string(), z.unknown())) @@ -78,8 +96,7 @@ export const createHarValidator = (): (( if (validate === undefined) throw new Error("Pinned HAR root schema is unavailable"); return (value) => { - const adaptedPlaywrightEntries = - allowObservedProducerRepresentations(value); + const adaptations = allowObservedProducerRepresentations(value); if (!validate(value)) { const error = validate.errors?.[0]; // AJV messages name the violated constraint, such as the missing @@ -92,36 +109,43 @@ export const createHarValidator = (): (( error?.instancePath ?? "", ); } - return adaptedPlaywrightEntries; + return adaptations; }; }; +/** Name the declared producer when it is observed to bracket IPv6 addresses. */ +const bracketedIpv6ProducerOf = ( + creator: Record, +): string | null => + BRACKETED_IPV6_PRODUCERS.find( + ({ name, version }) => creator.name === name && creator.version === version, + )?.label ?? null; + /** Adapt only observed producer representations in the disposable validation copy. */ const allowObservedProducerRepresentations = ( value: unknown, -): readonly number[] => { - if (!captureObject(value) || !captureObject(value.log)) return []; +): BracketedIpv6Adaptation | null => { + if (!captureObject(value) || !captureObject(value.log)) return null; const log = value.log; - if (!captureObject(log.creator) || !Array.isArray(log.entries)) return []; + if (!captureObject(log.creator) || !Array.isArray(log.entries)) return null; const mitmproxy = log.creator.name === "mitmproxy" && log.creator.version === "12.2.3"; - const playwright = - log.creator.name === "Playwright" && log.creator.version === "1.58.2"; - if (!mitmproxy && !playwright) return []; - const adaptedPlaywrightEntries: number[] = []; + const bracketedIpv6Producer = bracketedIpv6ProducerOf(log.creator); + if (!mitmproxy && bracketedIpv6Producer === null) return null; + const adaptedEntries: number[] = []; for (const [ordinal, entry] of log.entries.entries()) { const candidate: unknown = entry; if (!captureObject(candidate)) continue; const address = candidate.serverIPAddress; if ( - playwright && + bracketedIpv6Producer !== null && typeof address === "string" && address.startsWith("[") && address.endsWith("]") && isIP(address.slice(1, -1)) === 6 ) { candidate.serverIPAddress = address.slice(1, -1); - adaptedPlaywrightEntries.push(ordinal); + adaptedEntries.push(ordinal); } if (!mitmproxy) continue; const request = candidate.request; @@ -129,7 +153,9 @@ const allowObservedProducerRepresentations = ( const body = request.postData; if (captureObject(body) && body.text === null) delete body.text; } - return adaptedPlaywrightEntries; + return bracketedIpv6Producer === null + ? null + : { producer: bracketedIpv6Producer, entries: adaptedEntries }; }; const HAR_REQUEST_URL_FORMAT = "absolute-url"; diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts index 785b157220..6b0a7abd74 100644 --- a/src/browser/history/NodeCaptureAdapter.ts +++ b/src/browser/history/NodeCaptureAdapter.ts @@ -10,8 +10,8 @@ import { NODE_CAPTURE_HEAP_ENFORCED, captureDecoderProcessMemoryLimitBytes, } from "./CaptureRelease.js"; -import { QLOG_CAPTURE_PROVIDER_IDENTITY } from "./CaptureProviderIdentities.js"; import { + QLOG_CAPTURE_PROVIDER_IDENTITY, HAR_CAPTURE_PROVIDER_IDENTITY, SAZ_CAPTURE_PROVIDER_IDENTITY, } from "./CaptureProviderIdentities.js"; diff --git a/src/browser/modules/NativeModuleResolver.test.ts b/src/browser/modules/NativeModuleResolver.test.ts index 1619e8fc44..1a11a15f9c 100644 --- a/src/browser/modules/NativeModuleResolver.test.ts +++ b/src/browser/modules/NativeModuleResolver.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; -import { NativeModuleResolver } from "./NativeModuleResolver.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + NativeModuleResolver, + type ModuleResolverLauncher, +} from "./NativeModuleResolver.js"; import { webModuleResolutionObservationSchema } from "../../domain/webModuleTrace.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; @@ -218,6 +221,61 @@ describe("optional native resolution acquisition", () => { }); }); +describe("native resolution Chromium sandbox", () => { + const hostPlatform = process.platform; + afterEach(() => { + Object.defineProperty(process, "platform", { value: hostPlatform }); + }); + + it.each([ + { environment: {}, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "false" }, sandbox: true }, + { environment: { REA_BROWSER_NO_SANDBOX: "true" }, sandbox: false }, + ])( + "launches Chromium with sandbox $sandbox for $environment", + async ({ environment, sandbox }) => { + Object.defineProperty(process, "platform", { value: "linux" }); + let launchOptions: Parameters[0] | undefined; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser", ...environment }, + (options) => { + launchOptions = options; + return Promise.reject(new Error("launch boundary reached")); + }, + ); + const result = await provider.resolve(input); + if (result.ok) throw new Error("expected startup failure"); + expect(launchOptions?.chromiumSandbox).toBe(sandbox); + expect(launchOptions?.args).not.toContain("--no-sandbox"); + expect(result.error).toMatchObject({ + remediationAction: sandbox + ? expect.stringContaining("REA_BROWSER_NO_SANDBOX=true") + : undefined, + }); + }, + ); + + it("keeps the default recovery for engine failures after a sandboxed launch", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + () => + Promise.resolve({ + version: () => "fixture", + newContext: () => + Promise.reject(new Error("fixture context failure")), + close: () => Promise.resolve(), + }), + ); + const result = await provider.resolve(input); + if (result.ok) throw new Error("expected engine failure"); + expect(result.error).toMatchObject({ + remediationAction: undefined, + diagnostics: { error_message: "fixture context failure" }, + }); + }); +}); + describe("completed native resolution finalization", () => { it.each([false, true])( "retains the completed batch when final close is cancelled (close fails: %s)", diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index 4383a27e0c..0e2215f542 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -1,4 +1,8 @@ import { snapshotEnvironment } from "../../process/snapshotEnvironment.js"; +import { + chromiumLaunchRemediation, + chromiumSandboxEnabled, +} from "../ChromiumSandbox.js"; import { isAbsolute } from "node:path"; import { z } from "zod"; import type { @@ -33,6 +37,7 @@ import type { } from "../../domain/webModuleTrace.js"; import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js"; import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js"; +import { importMapForTrustedResolver } from "./importMapForTrustedResolver.js"; const OPERATION = "trace_web_module_imports"; const TIMEOUT_MS = 20000; @@ -72,6 +77,20 @@ export type ModuleResolverLauncher = ( const launch: ModuleResolverLauncher = async (options) => (await import("playwright-core")).chromium.launch(options); +const moduleResolverFailure = ( + cause: unknown, + executable: string, + remediationAction: string | undefined, +): ProviderAdapterError => + new ProviderAdapterError("native-module-resolver", OPERATION, { + cause, + ...(remediationAction === undefined ? {} : { remediationAction }), + diagnostics: { + executable, + error_message: cause instanceof Error ? cause.message : String(cause), + }, + }); + /** Use native import.meta.resolve in an owned context with locally fulfilled requests. */ export class NativeModuleResolver implements WebModuleResolutionPort { #tail: Promise = Promise.resolve(); @@ -131,6 +150,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { }, ), ); + const chromiumSandbox = chromiumSandboxEnabled(this.environment); const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal); let activeCall: | { settled: boolean; interruption?: AnalysisError } @@ -165,6 +185,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { ); }; let lease: AnalysisCleanupLease | undefined; + let launched = false; let cleanup: Promise | undefined; const close = (): Promise => { if (cleanup === undefined && lease !== undefined) { @@ -196,17 +217,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort { handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + chromiumSandbox, args: [ "--disable-dev-shm-usage", "--disable-background-networking", "--renderer-process-limit=1", "--js-flags=--max-old-space-size=128", - ...(this.environment.REA_BROWSER_NO_SANDBOX === "true" - ? ["--no-sandbox"] - : []), ], }), ); + launched = true; lease = new AnalysisCleanupLease(async () => { if ( deadline.signal.aborted && @@ -238,14 +258,15 @@ export class NativeModuleResolver implements WebModuleResolutionPort { cause instanceof AnalysisCapabilityUnavailableError || cause instanceof BrowserObservationError ? cause - : new ProviderAdapterError("native-module-resolver", OPERATION, { + : moduleResolverFailure( cause, - diagnostics: { - executable, - error_message: - cause instanceof Error ? cause.message : String(cause), - }, - }), + executable, + // Only an unresolved sandboxed launch can be a sandbox failure; + // later engine failures keep the default recovery guidance. + launched || !chromiumSandbox + ? undefined + : chromiumLaunchRemediation(true), + ), ); } finally { deadline.signal.removeEventListener("abort", onAbort); @@ -367,7 +388,7 @@ const resolveInBrowser = async ( await route.fulfill({ status: 200, contentType: "text/html", - body: `${input.importMap === null ? "" : ``}`, + body: `${input.importMap === null ? "" : ``}`, }); } else if ( !stubSent && diff --git a/src/browser/modules/importMapForTrustedResolver.test.ts b/src/browser/modules/importMapForTrustedResolver.test.ts new file mode 100644 index 0000000000..c1ae0f6200 --- /dev/null +++ b/src/browser/modules/importMapForTrustedResolver.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { importMapForTrustedResolver } from "./importMapForTrustedResolver.js"; + +describe("importMapForTrustedResolver", () => { + const baseUrl = "https://app.test/index.html"; + const importerUrl = "https://app.test/main.js"; + + it("omits integrity that covers the selected importer URL", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }, + }; + expect(importMapForTrustedResolver(map, importerUrl, baseUrl)).toEqual({ + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }); + }); + + it("resolves relative integrity keys against the import-map base URL", () => { + const map = { + imports: { dep: "./dep.js" }, + integrity: { + "./main.js": "sha256-importer", + "./dep.js": "sha256-dep", + }, + }; + expect( + importMapForTrustedResolver(map, "https://app.test/main.js", baseUrl), + ).toEqual({ + imports: { dep: "./dep.js" }, + integrity: { "./dep.js": "sha256-dep" }, + }); + }); + + it("matches importer integrity across query and fragment differences", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }, + }; + expect( + importMapForTrustedResolver( + map, + "https://app.test/main.js?build=1#entry", + baseUrl, + ), + ).toEqual({ + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }); + }); + + it("drops an empty integrity member after removing the importer entry", () => { + const map = { + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/main.js": "sha256-importer" }, + }; + expect(importMapForTrustedResolver(map, importerUrl, baseUrl)).toEqual({ + imports: { dep: "/dep.js" }, + }); + }); + + it("preserves maps without integrity and dependency-only integrity", () => { + const without = { imports: { dep: "/dep.js" } }; + expect(importMapForTrustedResolver(without, importerUrl, baseUrl)).toBe( + without, + ); + const dependencyOnly = { + imports: { dep: "/dep.js" }, + integrity: { "https://app.test/dep.js": "sha256-dep" }, + }; + expect( + importMapForTrustedResolver(dependencyOnly, importerUrl, baseUrl), + ).toBe(dependencyOnly); + }); + + it("preserves a dependency integrity key named __proto__ without prototype pollution", () => { + const integrity = Object.assign(Object.create(null), { + "https://app.test/main.js": "sha256-importer", + "https://app.test/dep.js": "sha256-dep", + }); + integrity["__proto__"] = "sha256-proto-dep"; + const map = { imports: { dep: "/dep.js" }, integrity }; + const next = importMapForTrustedResolver(map, importerUrl, baseUrl); + const nextIntegrity = next.integrity as { readonly [key: string]: string }; + expect(nextIntegrity["https://app.test/dep.js"]).toBe("sha256-dep"); + expect( + Object.getOwnPropertyDescriptor(nextIntegrity, "__proto__")?.value, + ).toBe("sha256-proto-dep"); + expect(Object.getPrototypeOf(nextIntegrity)).toBe(null); + expect(Object.prototype.hasOwnProperty.call({}, "__proto__")).toBe(false); + }); +}); diff --git a/src/browser/modules/importMapForTrustedResolver.ts b/src/browser/modules/importMapForTrustedResolver.ts new file mode 100644 index 0000000000..f4e254e2e8 --- /dev/null +++ b/src/browser/modules/importMapForTrustedResolver.ts @@ -0,0 +1,75 @@ +import type { JsonValue } from "../../domain/jsonValue.js"; + +/** + * Build the import map installed for REA's trusted resolver stub. + * + * Chromium applies import-map `integrity` to the module script URL. The + * resolver fulfills that URL with REA's replacement bytes, so integrity that + * covers the selected importer must not be installed. Dependency integrity and + * the caller-selected map identity remain unchanged in Evidence. + */ +export const importMapForTrustedResolver = ( + map: { readonly [key: string]: JsonValue }, + importerUrl: string, + baseUrl: string, +): { readonly [key: string]: JsonValue } => { + const integrity = map.integrity; + if ( + integrity === null || + typeof integrity !== "object" || + Array.isArray(integrity) + ) + return map; + const importer = importerModuleIdentity(importerUrl, baseUrl); + const nextIntegrity: { [key: string]: JsonValue } = Object.create(null); + let removed = false; + for (const [key, value] of Object.entries(integrity)) { + if (integrityKeyMatchesImporter(key, baseUrl, importer)) { + removed = true; + continue; + } + nextIntegrity[key] = value; + } + if (!removed) return map; + if (Object.keys(nextIntegrity).length === 0) { + const { integrity: _omit, ...rest } = map; + return rest; + } + return { ...map, integrity: nextIntegrity }; +}; + +const importerModuleIdentity = ( + importerUrl: string, + baseUrl: string, +): { readonly hrefs: ReadonlySet; readonly moduleKey: string } => { + const absolute = new URL(importerUrl, baseUrl); + const withoutHash = new URL(absolute.href); + withoutHash.hash = ""; + return { + hrefs: new Set([absolute.href, withoutHash.href]), + // Chromium's integrity lookup reports the module URL without search/hash. + moduleKey: `${absolute.origin}${absolute.pathname}`, + }; +}; + +const integrityKeyMatchesImporter = ( + key: string, + baseUrl: string, + importer: { + readonly hrefs: ReadonlySet; + readonly moduleKey: string; + }, +): boolean => { + try { + const resolved = new URL(key, baseUrl); + const withoutHash = new URL(resolved.href); + withoutHash.hash = ""; + return ( + importer.hrefs.has(resolved.href) || + importer.hrefs.has(withoutHash.href) || + `${resolved.origin}${resolved.pathname}` === importer.moduleKey + ); + } catch { + return false; + } +}; diff --git a/src/cli.ts b/src/cli.ts index bfd1b2b9a6..86508560a3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -79,7 +79,10 @@ import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringComm import { registerCutterCommands } from "./cli/cutterCommands.js"; import { excludeDeclaredSensitiveArguments } from "./cli/explicitSensitiveArguments.js"; import { rejectSurplusCliArguments } from "./cli/strictPositionals.js"; -import { presentCliArgumentErrors } from "./cli/argumentErrors.js"; +import { + encodeCliArgumentErrors, + presentCliArgumentErrors, +} from "./cli/argumentErrors.js"; import { completionHelpNote, createCliCompletions } from "./cli/completions.js"; import { closeCliGuidanceResources, @@ -247,6 +250,7 @@ export const createCli = ( const serveStrict = rejectSurplusCliArguments(cli); presentCliArgumentErrors(cli); const serveDeclared = excludeDeclaredSensitiveArguments(cli); + encodeCliArgumentErrors(cli); const serve = cli.serve.bind(cli); const complete = createCliCompletions( cli as unknown as Parameters[0], @@ -254,7 +258,7 @@ export const createCli = ( ); cli.serve = (arguments_ = process.argv.slice(2), options) => { if (complete(arguments_, options ?? {})) return Promise.resolve(); - const normalized = normalizeCliArguments(arguments_); + let normalized = normalizeCliArguments(arguments_); const { command, flags } = splitIncurBuiltinArguments(normalized); const builtin = command[0] === PRODUCT_IDENTITY.cliBinary ? 1 : 0; if ( @@ -305,6 +309,67 @@ export const createCli = ( )(1); return Promise.resolve(); } + // Incur's `mcp add` silently skips a valued option with no value and + // swallows the next flag as the value: the registration succeeds without + // the requested agent or command. Reject a trailing --agent/--command/-c, + // a value position occupied by another flag, and an empty `--flag=` form + // up front. + const valued = ["--agent", "--command", "-c"]; + let missingValue: string | undefined; + for (let index = 0; index < command.length; index += 1) { + const next = command[index + 1]; + if ( + valued.includes(command[index]!) && + (next === undefined || next.startsWith("-")) + ) { + missingValue = command[index]; + break; + } + } + const emptyValue = command.find( + (token) => + (token.startsWith("--agent=") || + token.startsWith("--command=") || + token.startsWith("-c=")) && + token.length === token.indexOf("=") + 1, + ); + if (missingValue !== undefined || emptyValue !== undefined) { + const flag = missingValue ?? emptyValue!; + const message = `Missing value for flag: ${flag.replace(/=.*$/, "")}`; + const field = + flag.includes("command") || flag === "-c" || flag.startsWith("-c=") + ? "command" + : "agent"; + const output = parseCliOutputArguments(normalized); + if (!output.validation.ok) + write(renderCliOutputArgumentError(output.validation)); + else + write( + renderCliDocument( + { + code: "VALIDATION_ERROR", + message, + fieldErrors: [ + { + code: "invalid_value", + missing: false, + path: field, + message, + }, + ], + }, + output.format, + process.stdout.isTTY === true, + ), + ); + ( + options?.exit ?? + ((code: number) => { + process.exitCode = code; + }) + )(1); + return Promise.resolve(); + } // Incur's mcp add parser only understands spaced `--agent amp` forms; an // equals form like `--agent=amp` is silently ignored, which would drop // the requested agent and register against the default target. Normalize @@ -322,10 +387,34 @@ export const createCli = ( // prevents a save. Guard that path: reject a settings file REA cannot // update, back up the file, preserve the caller's environment, and verify // the registration landed. + // SyncMcp.register also writes Amp when no agents are selected. Mode + // switches return before registration and must not touch its settings. + // Use the same normalized arguments for selection and dispatch, including + // non-Amp equals forms. Incur consumes command values before agent flags. + normalized = serveArguments; + const registration = splitIncurBuiltinArguments(serveArguments); + const rest = registration.command.slice(builtin + 2); + const agents: string[] = []; + for (let index = 0; index < rest.length; index += 1) { + if ( + (rest[index] === "--command" || rest[index] === "-c") && + rest[index + 1] + ) + index += 1; + else if (rest[index] === "--agent" && rest[index + 1]) + agents.push(rest[++index]!); + } const ampRequested = - command.some( - (token, index) => token === "--agent" && command[index + 1] === "amp", - ) || command.includes("--agent=amp"); + ![ + "--help", + "-h", + "--llms", + "--llms-full", + "--mcp", + "--update", + "--incur-update-check", + ].some((flag) => registration.flags.has(flag)) && + (agents.length === 0 || agents.includes("amp")); if (ampRequested) { const settingsPath = join(homedir(), ".config", "amp", "settings.json"); let priorEnv: unknown; @@ -411,9 +500,17 @@ export const createCli = ( } const runRegistration = (backupPath?: string): Promise => { + let failed = false; const result = serveStrict( serveArguments, - options ?? {}, + { + ...options, + exit: (code) => { + failed ||= code !== 0; + if (options?.exit !== undefined) options.exit(code); + else process.exitCode = code; + }, + }, (tokens, selected) => serveDeclared(tokens, selected, (declared, declaredOptions) => serve( @@ -423,6 +520,7 @@ export const createCli = ( ), ); return result.then(() => { + if (failed) return; let failure: string | undefined; try { if (!existsSync(settingsPath)) diff --git a/src/cli/analysisEvidenceDelivery.ts b/src/cli/analysisEvidenceDelivery.ts index 1858bd3fa8..0517c8acbc 100644 --- a/src/cli/analysisEvidenceDelivery.ts +++ b/src/cli/analysisEvidenceDelivery.ts @@ -6,6 +6,7 @@ import { import type { AnalysisViewRequest } from "../domain/analysisView/analysisView.js"; import { z } from "incur"; import { + isImmutableEvidence, parseEvidence, withEvidenceCallerInputPath, type Evidence, @@ -13,10 +14,14 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { EvidenceReferenceError } from "../domain/evidenceErrors.js"; import { redactExplicitFailure } from "../domain/explicitSensitiveFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { AnalysisInputError } from "../domain/analysisErrorCore.js"; -import { createEvidenceBundle } from "../domain/evidenceBundle.js"; +import { + createEvidenceBundle, + createImmutableEvidenceBundle, +} from "../domain/evidenceBundle.js"; import { evidenceDeliveryCommitted, publishEvidenceDelivery, @@ -32,6 +37,8 @@ import { import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { ZodSafeParseResult } from "zod"; import { bindCliEvidenceCallerPath } from "./cliEvidenceCallerPath.js"; +import type { AnalysisResultWithSources } from "../application/investigation/recordEvidenceSources.js"; +import { resolveInvestigationEvidenceClosure } from "../application/investigation/InvestigationEvidence.js"; export const analysisEvidenceDeliveryOptions = { detail: z @@ -74,6 +81,42 @@ export const cliEvidenceDeliveryInputError = ( ]) : undefined; +/** Resolve only the authenticated Evidence records linked by one CLI result. */ +export const cliEvidenceSourceClosure = ( + root: Evidence, + available: readonly Evidence[], +): Result => { + const recordsById = new Map( + available.map((record) => [record.evidence_id, record]), + ); + const closure = resolveInvestigationEvidenceClosure( + { evidenceForAnalysis: (evidenceId) => recordsById.get(evidenceId) }, + root.evidence_links, + ); + if (!closure.ok) { + if (closure.error instanceof EvidenceReferenceError) + return err( + new EvidenceReferenceError( + closure.error.evidenceId, + closure.error.reason, + { + cause: closure.error, + expected: closure.error.expected, + actual: closure.error.actual, + referenceScope: closure.error.referenceScope, + diagnostics: { operation: root.operation }, + }, + ), + ); + return err(closure.error); + } + return ok( + closure.value + .filter((evidenceId) => evidenceId !== root.evidence_id) + .map((evidenceId) => recordsById.get(evidenceId)!), + ); +}; + /** Parse delivery JSON once and enforce its owning schema before opening a target. */ export const parseCliAnalysisEvidenceDelivery = < Delivery extends { @@ -203,17 +246,28 @@ const withUndeliveredSourceClosure = ( /** Publish already finalized CLI analysis without opening a second provider/session. */ export const publishCliAnalysisEvidence = async ( - result: Result, + result: AnalysisResultWithSources, input: CliEvidenceDelivery, ): Promise => { const { operation, signal, detail, evidenceOutput } = input; + // Failure reporting uses the same captured sources as publication. A trace + // carries them on the result, while other commands pass them on the input. + const capturedSources = [ + ...(input.sourceEvidence ?? []), + ...(result.sourceEvidence ?? []), + ]; + const closureInput = + capturedSources.length === 0 + ? input + : { ...input, sourceEvidence: capturedSources }; const projectFailure = (error: AnalysisError) => projectAnalysisError( redactExplicitFailure(error, input.sensitiveValues ?? []), ); const undelivered = (error: AnalysisError) => - withUndeliveredSourceClosure(projectFailure(error), input); - if (!result.ok) return withUndeliveredSourceClosure(result.error, input); + withUndeliveredSourceClosure(projectFailure(error), closureInput); + if (!result.ok) + return withUndeliveredSourceClosure(result.error, closureInput); const complete = parseEvidence(result.value); const inputError = cliEvidenceDeliveryInputError(input); if (inputError !== undefined) { @@ -240,14 +294,21 @@ export const publishCliAnalysisEvidence = async ( : delivered.value; const inspectionSources = analysisEvidenceSources(complete); if (!inspectionSources.ok) return undelivered(inspectionSources.error); + const records = [ + ...new Map( + [...capturedSources, ...inspectionSources.value].map((record) => [ + record.evidence_id, + record, + ]), + ).values(), + ]; + // Only skip the writer's heap check when every record is already sealed. + // A mutable producer record must retain the existing allocation check. + const bundle = records.every(isImmutableEvidence) + ? createImmutableEvidenceBundle(records) + : createEvidenceBundle(records); const published = await publishEvidenceDelivery({ - bundle: createEvidenceBundle([ - ...new Map( - [...(input.sourceEvidence ?? []), ...inspectionSources.value].map( - (record) => [record.evidence_id, record], - ), - ).values(), - ]), + bundle, complete, delivered: selected, outputPath: absoluteCallerPath(evidenceOutput), diff --git a/src/cli/analysisViewCommands.ts b/src/cli/analysisViewCommands.ts index a6fea44e69..90f8b86947 100644 --- a/src/cli/analysisViewCommands.ts +++ b/src/cli/analysisViewCommands.ts @@ -26,12 +26,12 @@ export const registerAnalysisViewCommands = ( ): void => { cli.command(CLI_COMMANDS.inspectEvidenceBundle, { description: - "Inspect a local Evidence bundle completely, as discovery metadata, or as a page of selected complete records", + "Inspect a local Evidence bundle completely, as discovery metadata, as a page of selected complete records, or as direct-link and retained-closure membership.", args: z.object({ inputJson: z .string() .describe( - "JSON or JSON file with absolute path, detail, optional record filters, and records offset/limit", + "JSON or JSON file with absolute path, detail, optional record filters, records or links offset/limit, and links traversal/link_status", ), }), run: ({ args }) => @@ -73,7 +73,7 @@ export const registerAnalysisViewCommands = ( }); cli.command(CLI_COMMANDS.inspectAnalysisView, { description: - "Project a selected view of completed Evidence JSON for a supported analysis operation", + "Project a selected view of completed Evidence JSON for a supported analysis operation. For inventory rows, use --format csv or --format tsv with --filter-output normalized_result.items and --columns logical_path,artifact_id (if those fields are included in the view).", args: z.object({ inputJson: z.string().describe("Inline workflow JSON or JSON file path"), }), diff --git a/src/cli/argumentErrors.ts b/src/cli/argumentErrors.ts index 813dc4b131..7249c22fa7 100644 --- a/src/cli/argumentErrors.ts +++ b/src/cli/argumentErrors.ts @@ -2,6 +2,7 @@ import { Errors } from "incur"; import { z } from "zod"; import { projectInputIssues } from "../domain/inputIssueProjection.js"; +import { encodeMarkdownTableCell } from "./cliOutputFormatter.js"; import type { CliInstance } from "./types.js"; import { visitRegisteredCommands } from "./visitRegisteredCommands.js"; @@ -46,6 +47,37 @@ export const presentCliArgumentErrors = (cli: CliInstance): void => { }); }; +/** Encode only at the final Markdown presentation boundary, after redaction. */ +export const encodeCliArgumentErrors = (cli: CliInstance): void => { + visitRegisteredCommands(cli, (command) => { + command.middleware = [ + async (context, next) => { + try { + await next(); + } catch (error: unknown) { + if ( + context.format === "md" && + error instanceof Errors.ValidationError + ) { + error.fieldErrors = error.fieldErrors.map((field) => ({ + ...field, + ...(field.code === undefined + ? {} + : { code: encodeMarkdownTableCell(field.code) }), + path: encodeMarkdownTableCell(field.path), + expected: encodeMarkdownTableCell(field.expected), + received: encodeMarkdownTableCell(field.received), + message: encodeMarkdownTableCell(field.message), + })); + } + throw error; + } + }, + ...(command.middleware ?? []), + ]; + }); +}; + /** Retain safe selector constraints dropped by Incur's parser projection. */ const adbSelectorConstraints = ( command: string, diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts index 79e2b9b6ae..2431d916e3 100644 --- a/src/cli/artifactCommands.ts +++ b/src/cli/artifactCommands.ts @@ -13,6 +13,7 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ARTIFACT_GRAPH_PROVIDER } from "../domain/investigationProviders.js"; import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import { z } from "incur"; import type { DirectAnalysis } from "../composition/directAnalysis.js"; @@ -43,7 +44,9 @@ export const registerArtifactCommands = ( description: "Inspect a Foundation keyed archive as an object-reference graph", args: z.object({ - path: z.string().describe("Foundation plist archive or Apple app bundle"), + path: localPathStringSchema.describe( + "Foundation plist archive or Apple app bundle", + ), archive: keyedArchiveInputSchema.shape.path.describe( "Relative archive path when the target is a bundle", ), @@ -87,7 +90,9 @@ const registerDylibResolutionCommand = ( description: "Trace dyld load-path resolution for a Mach-O or every executable in an app bundle", args: z.object({ - path: z.string().describe("Mach-O file or Apple .app bundle path"), + path: localPathStringSchema.describe( + "Mach-O file or Apple .app bundle path", + ), }), options: z.object({ root: z @@ -125,7 +130,9 @@ const registerAssetCatalogCommand = ( ): void => { cli.command(CLI_COMMANDS.inspectAssetCatalog, { description: "Inspect compiled Apple asset catalog metadata", - args: z.object({ path: z.string().describe("Apple .app bundle path") }), + args: z.object({ + path: localPathStringSchema.describe("Apple .app bundle path"), + }), options: z.object({ offset: z .number() @@ -162,7 +169,7 @@ const registerInterfaceBuilderCommand = ( description: "Decode compiled storyboard and nib archives from an app bundle", args: z.object({ - path: z.string().describe("Apple .app bundle path"), + path: localPathStringSchema.describe("Apple .app bundle path"), }), options: z.object({ maxDocuments: z @@ -257,7 +264,7 @@ const registerExtractionCommand = ( description: "Extract regular contents of supported artifacts; DMG extraction is unavailable", args: z.object({ - path: z.string().describe("Application or package path"), + path: localPathStringSchema.describe("Application or package path"), }), options: z.object({ integrityPolicy: z @@ -293,7 +300,7 @@ const registerInspectionCommand = ( description: "Inspect one artifact and return all available observations and next probes", args: z.object({ - path: z.string().min(1).describe("Application or package path"), + path: localPathStringSchema.describe("Application or package path"), }), options: z.object({ ...analysisEvidenceDeliveryOptions, diff --git a/src/cli/cliOutputFormatter.ts b/src/cli/cliOutputFormatter.ts index ed4cd5910b..9553ec6901 100644 --- a/src/cli/cliOutputFormatter.ts +++ b/src/cli/cliOutputFormatter.ts @@ -350,27 +350,29 @@ const encodeMarkdownWhitespace = (whitespace: string): string => const escapeMarkdownText = (value: string): string => escapeTableCell(value) .replace(/^(\s*)([-+=])/, "$1\\$2") - .replace(/^(\s*\d{1,9})([.)])(?=\s)/, "$1\\$2") + .replace(/^(\s*\d{1,9})([.)])(?=\s|$)/, "$1\\$2") .replace(/^[ \t]+/, encodeMarkdownWhitespace) .replace(/[ \t]+$/u, encodeMarkdownWhitespace); const markdownHeading = (path: readonly string[]): string => `## ${escapeMarkdownText(path.join("."))}\n\n`; +/** Encode literal text for a Markdown table cell, including edge whitespace. */ +export const encodeMarkdownTableCell = (value: string): string => + escapeTableCell(value).replace(/^[ \t]+|[ \t]+$/g, (whitespace) => + encodeMarkdownWhitespace(whitespace), + ); + const renderTable = ( headers: readonly string[], rows: readonly string[][], ): string => { if (headers.length === 0) return ""; - const encodeTableCell = (value: string): string => - escapeTableCell(value).replace(/^[ \t]+|[ \t]+$/g, (spaces) => - encodeMarkdownWhitespace(spaces), - ); - const escapedHeaders = headers.map(encodeTableCell); + const escapedHeaders = headers.map(encodeMarkdownTableCell); const widths = escapedHeaders.map((header) => header.length); for (const row of rows) { for (let index = 0; index < escapedHeaders.length; index += 1) { - const width = encodeTableCell(row[index] ?? "").length; + const width = encodeMarkdownTableCell(row[index] ?? "").length; if (width > widths[index]!) widths[index] = width; } } @@ -384,7 +386,9 @@ const renderTable = ( for (const row of rows) { lines.push( `| ${escapedHeaders - .map((_, index) => pad(encodeTableCell(row[index] ?? ""), index)) + .map((_, index) => + pad(encodeMarkdownTableCell(row[index] ?? ""), index), + ) .join(" | ")} |`, ); } diff --git a/src/cli/cliResultOutput.ts b/src/cli/cliResultOutput.ts index d3bde08fb4..4ea5c5fe87 100644 --- a/src/cli/cliResultOutput.ts +++ b/src/cli/cliResultOutput.ts @@ -303,7 +303,16 @@ export const createCliResultOutput = ( streaming = true; }, captureSdkOutput(text) { - if ((format !== "jsonl" && !sdkDocumentControls) || streaming || handled) + // JSONL always buffers for framing. Piped/redirected JSON also buffers so + // Incur's always-indented Formatter output can be rewritten compactly. + // Terminal JSON stays with Incur, which already matches the indent contract. + // sdkDocumentControls also buffers one-shot SDK documents for token windows. + const captureJson = format === "json" && !isTerminalOutput(destination); + if ( + (format !== "jsonl" && !sdkDocumentControls && !captureJson) || + streaming || + handled + ) return false; // Incur already materializes one-shot documents. Retain references only; // generator events bypass this buffer and remain incremental. @@ -321,6 +330,39 @@ export const createCliResultOutput = ( await writeOutputText(destination, ""); return; } + if (format === "json") { + const text = parts.join(""); + let result: unknown; + try { + result = JSON.parse(text); + } catch { + // Preserve non-JSON SDK output (e.g. completion hooks). Token-count + // only for SDK documents that opted into sdkDocumentControls. + if (exitCode === 0 && options.tokenCount && sdkDocumentControls) { + await writeOutputText( + destination, + `${estimateTokenCount(text)}\n`, + ); + return; + } + await writeOutputText(destination, text); + return; + } + // Compact rewrite must not skip token controls for SDK documents + // (`--llms` / `--schema` with --token-count on a pipe). + if (exitCode === 0 && options.tokenCount) { + const compact = [...jsonOutputParts(result, "json", false)].join( + "", + ); + await writeOutputText( + destination, + `${estimateTokenCount(compact)}\n`, + ); + return; + } + await writeJsonOutput(result, destination, "json"); + return; + } if (exitCode === 0) { if (options.tokenCount) await writeOutputText( diff --git a/src/cli/coreBinaryCommands.ts b/src/cli/coreBinaryCommands.ts index f9cf5206ef..599f53b2e2 100644 --- a/src/cli/coreBinaryCommands.ts +++ b/src/cli/coreBinaryCommands.ts @@ -357,24 +357,41 @@ const registerTraceCommand = ( .default(false) .describe("Match the query with exact letter case"), snapshot: snapshotSelectionOption, + ...analysisEvidenceDeliveryOptions, ...nativeOpenOptionFields, }), run: ({ args, options }) => - logCliCommand(logger, "trace", () => - runDirectAnalysis( - args.path, - "trace_feature", - { - query: resolveCliAnalysisSelector( - args.query, - options.query, - "query", - "trace_feature", - ), - case_sensitive: options.caseSensitive, - }, - directAnalysisOptionsFromCli(logger, options), - ).then(projectCliAnalysisResult), + withCommandCancellation((signal) => + logCliCommand(logger, "trace", async () => { + const delivery = { + providerId: "auto", + operation: "trace_feature", + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + }; + const inputError = cliEvidenceDeliveryInputError(delivery); + if (inputError !== undefined) return projectAnalysisError(inputError); + const complete = await runDirectAnalysis( + args.path, + "trace_feature", + { + query: resolveCliAnalysisSelector( + args.query, + options.query, + "query", + "trace_feature", + ), + case_sensitive: options.caseSensitive, + }, + { + ...directAnalysisOptionsFromCli(logger, options), + signal, + includeEvidenceSources: options.evidence_output !== undefined, + }, + ); + return publishCliAnalysisEvidence(complete, delivery); + }), ), }); }; diff --git a/src/cli/coreNativeCommands.ts b/src/cli/coreNativeCommands.ts index d438e4bb39..a9449dd4bf 100644 --- a/src/cli/coreNativeCommands.ts +++ b/src/cli/coreNativeCommands.ts @@ -30,9 +30,12 @@ import { withCommandCancellation } from "./commandCancellation.js"; import { analysisEvidenceDeliveryOptions, cliEvidenceDeliveryInputError, + cliEvidenceSourceClosure, publishCliAnalysisEvidence, } from "./analysisEvidenceDelivery.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; +import type { EvidenceBundle } from "../domain/evidenceBundle.js"; const bytePatternInputSchema = z .object(bytePatternFields) @@ -134,7 +137,8 @@ export const registerCoreNativeCommands = ( ), }); cli.command(CLI_COMMANDS.readBytes, { - description: "Read exact provider memory bytes at one analysis address", + description: + "Read provider memory bytes; Ghidra memory may include loader fixups and differ from file bytes", args: z.object({ path: localPathStringSchema.describe("Local executable path"), address: z.string().describe("Exact provider memory address"), @@ -159,7 +163,8 @@ export const registerCoreNativeCommands = ( ), }); cli.command(CLI_COMMANDS.addressToFileOffset, { - description: "Resolve an analysis address to its original file byte offset", + description: + "Map one analysis address to a file offset; memory bytes may differ and ranges may not be contiguous", args: z.object({ path: localPathStringSchema.describe("Local executable path"), address: z.string().describe("Exact provider memory address"), @@ -236,25 +241,69 @@ export const registerCoreNativeCommands = ( .max(5000) .default(1000) .describe("Maximum nodes on this page"), + ...analysisEvidenceDeliveryOptions, ...nativeOpenOptionFields, }), run: ({ args, options }) => - logCliCommand(logger, CLI_COMMANDS.traceNativeValues, () => - runDirectAnalysis( - args.path, - "trace_native_values", - { - procedure: args.procedure, - max_depth: options.maxDepth, - max_functions: options.maxFunctions, - max_call_sites: options.maxCallSites, - max_nodes: options.maxNodes, - max_edges: options.maxEdges, - offset: options.offset, - limit: options.limit, - }, - directAnalysisOptionsFromCli(logger, options), - ).then(projectCliAnalysisResult), + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.traceNativeValues, async () => { + const delivery = { + providerId: "auto", + operation: "trace_native_values", + signal, + detail: options.detail, + evidenceOutput: options.evidence_output, + }; + const invalid = cliEvidenceDeliveryInputError(delivery); + if (invalid !== undefined) return projectAnalysisError(invalid); + let sessionBundle: EvidenceBundle | undefined; + const complete = await runDirectAnalysis( + args.path, + "trace_native_values", + { + procedure: args.procedure, + max_depth: options.maxDepth, + max_functions: options.maxFunctions, + max_call_sites: options.maxCallSites, + max_nodes: options.maxNodes, + max_edges: options.maxEdges, + offset: options.offset, + limit: options.limit, + }, + { + ...directAnalysisOptionsFromCli(logger, options), + signal, + ...(options.evidence_output === undefined + ? {} + : { + onEvidenceBundle: (bundle: EvidenceBundle) => { + sessionBundle = bundle; + }, + }), + }, + ); + if (!complete.ok || options.evidence_output === undefined) + return publishCliAnalysisEvidence(complete, delivery); + if (sessionBundle === undefined) + return projectAnalysisError( + new AnalysisOutputError( + "trace_native_values", + "The CLI session did not return its retained Evidence bundle.", + ).retainPartialObservation(complete.value), + ); + const sources = cliEvidenceSourceClosure( + complete.value, + sessionBundle.records, + ); + if (!sources.ok) + return projectAnalysisError( + sources.error.retainPartialObservation(complete.value), + ); + return publishCliAnalysisEvidence(complete, { + ...delivery, + sourceEvidence: sources.value, + }); + }), ), }); cli.command(CLI_COMMANDS.inspectNativeDataType, { diff --git a/src/cli/explicitSensitiveArguments.ts b/src/cli/explicitSensitiveArguments.ts index c74a53ff22..5308a544e6 100644 --- a/src/cli/explicitSensitiveArguments.ts +++ b/src/cli/explicitSensitiveArguments.ts @@ -116,7 +116,7 @@ export const excludeDeclaredSensitiveArguments = ( command.options instanceof z.ZodObject && Object.hasOwn(command.options.shape, OPTION) ) - // Outermost, so later command middleware failures are covered as well + // Outside the command middleware, so its failures are covered as well // as the parsing and run that Incur executes inside the chain. command.middleware = [excludeFromFailures, ...(command.middleware ?? [])]; }); diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 43223a58cd..564b563c3e 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; import { z } from "incur"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; @@ -36,7 +37,7 @@ export const registerFirmwareCommands = ( callerPath, () => service.execute(operation, input, { signal }), service, - operation === "inspect_firmware_regions" ? "binwalk" : "unblob", + FIRMWARE_OPERATION_ENGINES[operation], operation, signal, ).then(projectCliAnalysisResult), diff --git a/src/cli/reverseEngineeringCommands.ts b/src/cli/reverseEngineeringCommands.ts index b07da0176b..59e715fb6d 100644 --- a/src/cli/reverseEngineeringCommands.ts +++ b/src/cli/reverseEngineeringCommands.ts @@ -1,5 +1,6 @@ import { z } from "incur"; import type { Logger } from "pino"; +import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js"; import { createReverseEngineeringService } from "../composition/reverseEngineering.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -28,6 +29,11 @@ import { createRizinDebugSessionManager, } from "../composition/debuggerSessions.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + retainedDebuggerDiagnostics, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../process/ownedDebuggerCleanupError.js"; import { analysisEvidenceDeliveryOptions, cliEvidenceDeliveryInputError, @@ -144,6 +150,9 @@ const closeDebuggerCommandSession = async ( try { await manager.closeAll(); } catch (cause) { + // This process is the only owner, so a caller gets the debugger to check + // rather than a retry that no later command could perform. + const retainedPids = retainedDebuggerProcessIds(cause); throw new ProviderCleanupError( providerId, sessionId === undefined ? [] : [sessionId], @@ -158,8 +167,15 @@ const closeDebuggerCommandSession = async ( : cause instanceof Error ? cause.message : String(cause), + ...retainedDebuggerDiagnostics(cause), + }, + { + cause, + operation, + ...(retainedPids.length === 0 + ? {} + : { remediationAction: retainedDebuggerRemediation(retainedPids) }), }, - { cause, operation }, ); } }; @@ -177,7 +193,7 @@ export const registerReverseEngineeringCommands = ( const service = createService(environment); cli.command(CLI_COMMANDS.inspectWithObjdump, { description: - "Inspect a local artifact with GNU objdump and retain its raw output as Evidence", + "Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence", args: z.object({ path: z .string() @@ -185,7 +201,7 @@ export const registerReverseEngineeringCommands = ( .describe("Path to the local artifact to inspect."), operation: z .enum(OBJDUMP_ARTIFACT_OPERATIONS) - .describe("GNU objdump inspection operation to run."), + .describe("GNU-style objdump operation to run."), }), options: z.object({ ...analysisEvidenceDeliveryOptions, @@ -193,7 +209,7 @@ export const registerReverseEngineeringCommands = ( .string() .optional() .describe( - "Native symbol to select with the disassemble operation; GNU objdump controls matching and boundaries.", + "Native symbol to select with the disassemble operation; the selected executable controls matching and boundaries.", ), followDebugLinks: z .boolean() @@ -207,7 +223,7 @@ export const registerReverseEngineeringCommands = ( withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => { const delivery = { - providerId: "gnu.objdump", + providerId: OBJDUMP_PROVIDER_IDENTITY.id, operation: "inspect_with_objdump", signal, detail: options.detail, @@ -240,7 +256,7 @@ export const registerReverseEngineeringCommands = ( ), }); cli.command(CLI_COMMANDS.listObjdumpArchitectures, { - description: "List BFD targets advertised by the installed GNU objdump", + description: "Capture the selected objdump command response to -i", args: z.object({}), options: z.object({ ...analysisEvidenceDeliveryOptions, @@ -253,7 +269,7 @@ export const registerReverseEngineeringCommands = ( CLI_COMMANDS.listObjdumpArchitectures, async () => { const delivery = { - providerId: "gnu.objdump", + providerId: OBJDUMP_PROVIDER_IDENTITY.id, operation: "inspect_with_objdump", signal, detail: options.detail, diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts index a75495a56b..088aa73241 100644 --- a/src/cliBuiltinArguments.ts +++ b/src/cliBuiltinArguments.ts @@ -31,6 +31,38 @@ export const INCUR_BUILTIN_FLAGS: ReadonlySet = new Set([ "--timings", ]); +/** Supported boolean spelling under the pinned Incur global parser. */ +const cliTimingFlagValue = (token: string): boolean | undefined => + token === "--timings" + ? true + : token === "--no-timings" + ? false + : token.startsWith("--timings=") + ? token.slice("--timings=".length) === "true" + : undefined; + +/** Remove only timing globals, preserving valued output data and the -- boundary. */ +export const splitCliTimingArguments = ( + arguments_: readonly string[], +): { arguments: string[]; enabled: boolean } => { + const values = new Set( + splitIncurBuiltinArguments(arguments_).outputValues.map( + ({ index }) => index, + ), + ); + let enabled = false; + let ended = false; + const remaining = arguments_.filter((token, index) => { + if (values.has(index)) return true; + if (token === "--") ended = true; + const value = ended ? undefined : cliTimingFlagValue(token); + if (value === undefined) return true; + enabled = value; + return false; + }); + return { arguments: remaining, enabled }; +}; + /** Command tokens and output-flag values as Incur's builtin pass assigns them. */ export interface IncurBuiltinArgumentSplit { /** Tokens left for command resolution and command option parsing. */ @@ -46,8 +78,8 @@ export interface IncurBuiltinArgumentSplit { /** * Mirror Incur's builtin pass over normalized arguments without stopping at a - * rejected value, so callers can still see every later token. REA's boolean - * timing global follows the same valueless pass; no config flag is registered. + * rejected value, so callers can still see every later token. Then mirror the + * boolean timing global pass over the remaining tokens; no config is registered. */ export const splitIncurBuiltinArguments = ( arguments_: readonly string[], @@ -59,7 +91,7 @@ export const splitIncurBuiltinArguments = ( const argument = arguments_[index]!; const next = arguments_[index + 1]; if ( - INCUR_BUILTIN_FLAGS.has(argument) || + (INCUR_BUILTIN_FLAGS.has(argument) && argument !== "--timings") || // A following value belongs to a command-local `--version` option. (argument === "--version" && (next === undefined || next.startsWith("-"))) ) { @@ -73,7 +105,19 @@ export const splitIncurBuiltinArguments = ( } command.push(argument); } - return { command, flags, outputValues }; + // Incur extracts builtin output values first, then parses globals until `--`. + // A timing-shaped output value is data; a command option's value is still + // subject to this global pass before the registration builtin consumes it. + let globalsEnded = false; + const remaining = command.filter((token) => { + if (token === "--") globalsEnded = true; + if (!globalsEnded && cliTimingFlagValue(token) !== undefined) { + flags.add(token); + return false; + } + return true; + }); + return { command: remaining, flags, outputValues }; }; /** @@ -102,7 +146,11 @@ export const normalizeCliArguments = ( normalized.push(flag, argument.slice(separator + 1)); else { if (argument === "--update") updates.add(normalized.length); - else if (!OUTPUT_SWITCHES.has(argument)) rootUpdate = false; + else if ( + !OUTPUT_SWITCHES.has(argument) && + cliTimingFlagValue(argument) === undefined + ) + rootUpdate = false; normalized.push(argument); } } diff --git a/src/config/ghidraExistingProject.ts b/src/config/ghidraExistingProject.ts index 3bc70e682c..387f6c48e3 100644 --- a/src/config/ghidraExistingProject.ts +++ b/src/config/ghidraExistingProject.ts @@ -1,15 +1,24 @@ import { isAbsolute, basename } from "node:path"; import { z } from "zod"; +/** Recovery for a packed Program selected where REA requires a project. */ +export const GHIDRA_PACKED_PROGRAM_RECOVERY = + "A .gzf is a packed program, not a .gpr project. Import it into a new project with Ghidra analyzeHeadless -import -noanalysis; select its .gpr and, if needed, set REA_GHIDRA_PROGRAM_NAME to the imported program name."; + /** A single expanded local project marker; archives must be restored first. */ export const ghidraProjectPathSchema = z .string() .min(1) .refine(isAbsolute, "Ghidra project path must be absolute") - .refine( - (path) => path.endsWith(".gpr") && basename(path).length > 4, - "Select an expanded .gpr project marker; restore .grez archives first", - ); + .superRefine((path, context) => { + if (path.endsWith(".gpr") && basename(path).length > 4) return; + context.addIssue({ + code: "custom", + message: path.toLowerCase().endsWith(".gzf") + ? GHIDRA_PACKED_PROGRAM_RECOVERY + : "Select an expanded .gpr project marker; restore .grez archives first", + }); + }); /** Headless -process accepts globs and flags; this workflow selects one root program. */ export const ghidraProgramNameSchema = z diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index 87fdfec068..ac0b51ef55 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_analysis_view"), kind: "application", description: - 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + 'Use javascript-routes collections to query route patterns, source-linked occurrences, source status, unknowns or summary counts from retained JavaScript application or web-bundle Evidence. Use web-bundle collections endpoints or inferences to page endpoint observations and inferences from retained analyze_web_bundle Evidence; summary returns target URL, origin, and counts. For retained trace_feature Evidence, kind native-feature-trace pages matches, references or unknowns; matches filter exact address, references filter exact source_address and target_address, source_index identifies each original row, and list_unknowns pages durable question heads. Inspect selected views of supported completed Evidence without repeating analysis. Choose a view compatible with the parent operation described below. Command summary views preserve ordered GDB records or opaque objdump stdout/stderr once, source parameters, completion facts and parent identity. They do not establish current target state. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. Native export tables use kind native-exports, collection exports/issues, exact names or original indices, selected fields and matching-row pagination; parent Evidence plus source_index identifies each row. Source coverage stays separate from page coverage. BEAM views use kind beam, collections atoms/exports/imports/lambdas/chunks, exact names/indices/fields and matching-row pages; import modules intersects function names. source_index identifies original parent-and-collection rows, preserving every arity and raw atom index. beam.counts preserves null missing tables versus decoded empty tables; runtime absence remains unknown. PYC views use kind python-pyc, collection code_objects, names/indices/fields and paging; source_index scopes every same-name alternative to the parent, nested_code keeps code-constant references, and pyc.coverage preserves source coverage. Go metadata uses kind go, collection dependencies/settings, exact paths/keys or indices, fields and pagination. Row index refers to the original collection; empty selections return no rows, and dependency coverage remains unknown. Rust metadata uses kind rust, collection packages/edges, exact names/versions or original indices, selected fields, edge from_indices/to_indices and matching-row pagination. Embedded-record coverage is separate from unknown runtime dependencies. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent also supports summary and browser-scenario steps/events/transactions/text pages, preserving step_indices, event_kinds, transaction_ids and selected artifacts. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized reads that are not entities and have one initial request line (item-shaped paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state (a list also learns the filters, search, sort and paging its recordings verify, and answers new values of them from the state), and every other read tool is a recorded view with a reason. It also reports a write plan per state-changing tool (create, update, delete or document, built from its first successful call) and write_clock; a plan is served only when replaying the capture shows a later read confirming its effect and none contradicting it. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. The environment-coverage view, view {kind: "environment-coverage", template_rules: [...]}, summarizes that state without its entities: reads.state and reads.recorded count read tools answered from the state and replayed (recorded_reasons per view reason), writes.served lists the served write tools, and writes.withheld lists each other write tool with its reason, plan record, contradicting read and a hint about what to record next; it refuses what the environment-state view refuses. For a retained record use source: {kind: "retained-evidence", evidence_id: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: ""}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. The file may also be an Evidence bundle written by CLI --evidence_output or export_evidence_bundle; select evidence_id when it contains more than one record. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings. Exact names and declaring_types filters intersect before paging where supported; fields selects columns (distinct from collection: "fields"). Identity/source_index, module MVID, source coverage and unresolved filter counts remain; omit body to avoid IL anchors. Omitted fields preserves full rows. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. APK signing views use kind apk-signature and collection signers/digests/lineage, with ids, signer_ids, schemes, fields and paging. Identity links remain when fields is empty. Declared identities and digest matches do not establish signature verification or authenticity. Model views use kind model-artifact and collection metadata (keys), tensors (names/types) or types (derived totals), with indices, fields and paging. Header SHA-256 scopes declarations; full-file identity remains unknown unless full hashing was selected. Arrays retain typed eight-element previews with explicit omissions. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals/code_gen_modules/method_addresses/return_types/field_types/parameter_types/generic_method_instances/generic_method_issues. Select indices, fields and pages; sections/images/types/methods/fields/properties/events/parameters/return_types/field_types/parameter_types/generic_method_instances accept exact names (native type names match retained rendered values, preserving same-name rows; null names never match), types also accept image_indices/namespaces/declaring_types, and methods/members accept declaring_types. Parameters and parameter_types accept method_indices; code_gen_modules accept image_indices; method_addresses and generic_method_instances accept addresses/states (generic_method_instances also method_indices), preserving every folded alternative; generic_method_issues accept rows. Row index is scoped to the metadata digest; native pages add binary_source. Uncollected native tables have null counts and remain unavailable. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native load-image Evidence uses kind native-load-image with collection source_files/mappings/relocations/entry_points/entry_context/checks/sections, original indices, collection-specific fields and offset/limit paging. Null collections remain unrecorded or inapplicable; pages preserve unsupported verification. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence accepts kind summary for identity, availability and facet counts, or kind native for selected facts: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Retained batch_decompile Evidence accepts kind summary for counts and sizes, or view: {kind: "batch-decompile", collection: "items", where: {statuses: ["unavailable", "error"]}} for body-free rows with exact indices, addresses, procedure_addresses or statuses (include_pseudocode adds bodies), or collection "pseudocode" with an index to page one body. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index e8bb3cfb27..fe47b7f5d7 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -372,7 +372,7 @@ export const APPLICATION_TOOL_CONTRACTS = [ name: "project_harmony_application_graph", ...toolContractMetadata("project_harmony_application_graph"), description: - "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an execution-free application inventory. Reports packaging-model hints (Stage module.json versus FA config.json), exact component paths and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing), App Pack child packages, runtime-family hints, and path-based N-API bridge hypotheses without decoding Ark bytecode, parsing manifest semantics, or claiming observed runtime calls.", + "Project authenticated HarmonyOS HAP, HSP, or App Pack inventory Evidence into an execution-free application inventory. Reports packaging-model hints (Stage module.json versus FA config.json), exact component paths and hashes (manifests, Ark bytecode, resources, native libraries, JavaScript, signing), authenticated files outside those categories in unclassified_components, App Pack child packages, runtime-family hints, and path-based N-API bridge hypotheses without decoding Ark bytecode, parsing manifest semantics, or claiming observed runtime calls.", kind: "application", inputSchema: applicationInventoryProjectionInputSchema, outputSchema: evidenceResultOf(harmonyApplicationProjectionResultSchema), diff --git a/src/contracts/compactInputSchema.ts b/src/contracts/compactInputSchema.ts index e7ec54ae53..d45329674f 100644 --- a/src/contracts/compactInputSchema.ts +++ b/src/contracts/compactInputSchema.ts @@ -2,6 +2,7 @@ import { COMPACT_SCHEMA_MAX_DEPTH, hasGatewayIncompatibleShape, inlineWithinBudget, + isSchemaObject, reduceToShallowProperties, schemaDepth, serializedBytes, @@ -28,6 +29,34 @@ export interface CompactedInputSchema { const INPUT_REDUCED_ANNOTATION = "Advertised in reduced form by the compact input schema profile; the server still validates complete canonical inputs."; +// `detail` selects between complete Evidence and a bounded summary. Without +// its guidance, callers default to complete replies that can exceed context. +const retainedRootPropertyDescriptions = ["detail"]; + +/** Restore the description of root properties whose choice bounds the reply. */ +const retainRootPropertyGuidance = ( + schema: Record, + stripped: Record, +): Record => { + if ( + !isSchemaObject(schema.properties) || + !isSchemaObject(stripped.properties) + ) + return stripped; + const properties = { ...stripped.properties }; + for (const name of retainedRootPropertyDescriptions) { + const source = schema.properties[name]; + const kept = properties[name]; + if ( + isSchemaObject(source) && + isSchemaObject(kept) && + typeof source.description === "string" + ) + properties[name] = { ...kept, description: source.description }; + } + return { ...stripped, properties }; +}; + /** * Render an advertised input schema within a per-schema byte budget for * providers that validate the literal serialized schema size. Validation @@ -41,7 +70,9 @@ export const compactAdvertisedInputSchema = ( ): CompactedInputSchema => { const expanded = inlineWithinBudget(stripAnnotations(schema), budgetBytes); const inline = - expanded === undefined ? undefined : typeArrayItemUnions(expanded); + expanded === undefined + ? undefined + : typeArrayItemUnions(retainRootPropertyGuidance(schema, expanded)); if ( inline !== undefined && serializedBytes(inline) <= budgetBytes && diff --git a/src/contracts/decompileDirectory.ts b/src/contracts/decompileDirectory.ts index bb584f8437..38dd2b7dfc 100644 --- a/src/contracts/decompileDirectory.ts +++ b/src/contracts/decompileDirectory.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { digestSchema } from "../domain/digests.js"; import { absoluteLocalPathStringSchema } from "../domain/localPath.js"; +import { analysisSourceDescriptorSchema } from "../domain/analysisSource.js"; /** On-disk journal. Append-only so a crash keeps committed function files. */ export const DECOMPILE_DIRECTORY_JOURNAL_NAME = "decompile-journal.jsonl"; @@ -245,6 +246,22 @@ export const decompileDirectoryFunctionSchema = z ) .optional(), sha256: digestSchema.optional(), + /** Source of this attempt's pseudocode observation; a matched file's original producer remains unknown. */ + provider_source: z + .discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("reported"), + source: analysisSourceDescriptorSchema, + }), + z.strictObject({ + status: z.literal("unknown"), + reason: z.enum([ + "source_not_available", + "existing_file_origin_unknown", + ]), + }), + ]) + .optional(), body: bodySchema, failure: z .strictObject({ diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index 8952f42447..306a9febc6 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -47,7 +47,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "batch_decompile", - "Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item success or error results and aggregate counts. An item whose provider answer has no pseudocode keeps that observation's limitations, including any reason the provider reported. The complete reply carries every pseudocode body. Select detail summary to retain the batch and receive only counts, then read rows or one body by index through inspect_analysis_view. A per-function limit is not a batch deadline; allow for every item or split calls.", + "Decompile each explicit procedure symbol or address, with one provider read active per batch. Preserves each input selector in address and reports the observed canonical entry and entry label in procedure; identity or label failures remain explicit without discarding usable pseudocode. Returns ordered per-item outcomes and aggregate counts. An unavailable item means the provider returned null pseudocode; its limitations retain any reported reason without asserting provider capability or retryability. Errors report failed operations or malformed output. Failed counts include unavailable items. The complete reply carries every pseudocode body. Select detail summary to retain the batch and receive only counts, then read rows or one body by index through inspect_analysis_view. A per-function limit is not a batch deadline; allow for every item or split calls.", enhancedInputSchemas.batch_decompile.extend({ detail: analysisDetailSchema, }), @@ -82,8 +82,8 @@ export const ENHANCED_TOOL_CONTRACTS = [ ...FUNCTION_WORKFLOW_TOOL_CONTRACTS, enhanced( "trace_feature", - "Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap.", - enhancedInputSchemas.trace_feature, + "Trace the complete literal query, preserving spaces, through every matching string and procedure and their xrefs. Preserve an explicit unavailable owner for a non-retryable containment capability refusal. Returns observations and residual unknowns without inferring reference kinds; truncated only reports an observed reference cap. For a procedure match, use procedure_references with direction incoming for provider-reported kinds when supported; it covers the procedure body, so compare target addresses when selecting exact-entry references. Select detail summary to retain complete Evidence and page matches, references and unknowns with inspect_analysis_view kind native-feature-trace.", + enhancedInputSchemas.trace_feature.extend({ detail: analysisDetailSchema }), ), enhanced( "trace_call_path", diff --git a/src/contracts/evidenceInputConformance.test.ts b/src/contracts/evidenceInputConformance.test.ts index 0950af031e..9dd1b8c900 100644 --- a/src/contracts/evidenceInputConformance.test.ts +++ b/src/contracts/evidenceInputConformance.test.ts @@ -230,6 +230,34 @@ describe("public Evidence input declarations", () => { expect(declarationIssues(schema)).toEqual([]); }); + it("distinguishes replaceable inline Evidence from reference-only sources", () => { + for (const schema of [ + evidenceInputSchema, + evidenceSourceInputSchema, + analysisViewSourceSchema, + z.array(evidenceInputSchema.optional()), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(true); + for (const schema of [ + retainedEvidenceReferenceSchema, + evidenceFileReferenceSchema, + exportEnvironmentBundleInputSchema.shape.source, + z.object({ address: z.string() }), + ]) + expect( + getEvidenceInputContract(schema).hasEvidenceReferenceAlternative, + ).toBe(false); + // Reference guidance must not change the separately owned source parser. + expect( + getEvidenceInputContract(evidenceSourceInputSchema).hasEvidence, + ).toBe(false); + expect(getEvidenceInputContract(analysisViewSourceSchema).hasEvidence).toBe( + false, + ); + }); + it("rejects skipping defaults above Evidence-bearing request unions", () => { const choice = z.union([ z.strictObject({ diff --git a/src/contracts/firmware/firmwareToolContracts.ts b/src/contracts/firmware/firmwareToolContracts.ts index 2bb8e19980..c210212271 100644 --- a/src/contracts/firmware/firmwareToolContracts.ts +++ b/src/contracts/firmware/firmwareToolContracts.ts @@ -5,7 +5,10 @@ import { import type { ToolContract } from "../toolContractTypes.js"; import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; import { toolContractMetadata } from "../toolEffects.js"; -import { FIRMWARE_TOOL_REQUIREMENTS } from "./firmwareToolRequirements.js"; +import { + FIRMWARE_OPERATION_ENGINES, + FIRMWARE_TOOL_REQUIREMENTS, +} from "./firmwareToolRequirements.js"; /** Explicit local firmware observations and extraction, backed by caller-supplied tools. */ export const FIRMWARE_TOOL_CONTRACTS = [ @@ -13,7 +16,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "inspect_firmware_regions", ...toolContractMetadata("inspect_firmware_regions"), kind: "firmware-provider", - description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS.binwalk.requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, + description: `Inspect signature candidates in a local firmware file with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.inspect_firmware_regions].requirement}. Returns offsets, reported lengths, descriptions and raw confidence values. Length validation and runtime addresses remain unknown. No extraction or target execution. Linux, caller-supplied tool, util-linux prlimit and procps ps required; 128 MiB input limit, one worker. Adjust analysis_timeout_ms for the analysis wall-clock budget (default 120000); fixed per-process limits are unchanged.`, inputSchema: firmwareInputSchemas.inspect_firmware_regions, outputSchema: evidenceResultOf( firmwareResultSchemas.inspect_firmware_regions, @@ -29,7 +32,7 @@ export const FIRMWARE_TOOL_CONTRACTS = [ name: "extract_firmware", ...toolContractMetadata("extract_firmware"), kind: "firmware-provider", - description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS.unblob.requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, + description: `Extract caller-selected firmware bytes with ${FIRMWARE_TOOL_REQUIREMENTS[FIRMWARE_OPERATION_ENGINES.extract_firmware].requirement} into an absent output directory. Returns verified child paths/digests, task derivations, unknown chunks and uncovered selected input ranges, extraction diagnostics and explicit extraction_gaps rows naming unextracted regions, missing extractor dependencies and a continuation_id referencing shared extraction_continuations recovery instructions. Preserves the default sandbox; publishes regular files only. Linux, caller-supplied tool, format-specific extractors, util-linux prlimit and procps ps required. No mounts or extracted-code execution. Select a returned native file with open_binary for Ghidra analysis; decompressed offsets are not original-file addresses.`, inputSchema: firmwareInputSchemas.extract_firmware, outputSchema: evidenceResultOf(firmwareResultSchemas.extract_firmware), examples: [ diff --git a/src/contracts/firmware/firmwareToolRequirements.ts b/src/contracts/firmware/firmwareToolRequirements.ts index fb0816fe97..824a43816a 100644 --- a/src/contracts/firmware/firmwareToolRequirements.ts +++ b/src/contracts/firmware/firmwareToolRequirements.ts @@ -1,3 +1,5 @@ +import type { FirmwareOperation } from "../../domain/firmware/firmwareAnalysis.js"; + /** Derive public support facts from one audited, stable release number. */ const defineToolRequirement = (release: { readonly name: string; @@ -39,3 +41,9 @@ export const FIRMWARE_TOOL_REQUIREMENTS = { /** Caller-supplied firmware engine selected by the operation. */ export type FirmwareEngineName = keyof typeof FIRMWARE_TOOL_REQUIREMENTS; + +/** The engine required by each existing firmware operation, shared by discovery and execution. */ +export const FIRMWARE_OPERATION_ENGINES = { + inspect_firmware_regions: "binwalk", + extract_firmware: "unblob", +} as const satisfies Readonly>; diff --git a/src/contracts/functionWorkflowToolContracts.ts b/src/contracts/functionWorkflowToolContracts.ts index cc360d916b..a1c8fcb9c8 100644 --- a/src/contracts/functionWorkflowToolContracts.ts +++ b/src/contracts/functionWorkflowToolContracts.ts @@ -50,7 +50,7 @@ const functionWorkflow = ( export const FUNCTION_WORKFLOW_TOOL_CONTRACTS = [ functionWorkflow( "analyze_function", - "Build a dossier for one native function identified by symbol or provider-returned address. Returns identity with complete inclusive analyzed body ranges or explicit unknown, provider-specific pseudocode and assembly, comments, calls, references, referenced strings/names, local CFG blocks, and available native API boundary observations. Ghidra terminal-call assumptions that may cut the analyzed body are reported in limitations with available callee identities. Default detail summary returns a compact dossier (identity, pseudocode size, facet counts, value-flow availability) and retains the complete dossier Evidence in this session; page pseudocode, references, assembly and value-flow through normalized_result.parent_evidence_id with inspect_analysis_view. detail complete returns the full dossier inline.", + "Build a dossier for one native function identified by symbol or provider-returned address. Returns identity with complete inclusive analyzed body ranges or explicit unknown, provider-specific pseudocode and assembly, comments, calls, references, referenced strings/names, local CFG blocks, and available native API boundary observations. Ghidra terminal-call assumptions that may cut the analyzed body are reported in limitations with available callee identities. Default detail summary returns a compact dossier (identity, pseudocode size, facet counts, value-flow availability) and retains the complete dossier Evidence in this session; page pseudocode, references, assembly, comments and value-flow through normalized_result.parent_evidence_id with inspect_analysis_view. detail complete returns the full dossier inline.", ), functionWorkflow( "inspect_native_api", diff --git a/src/contracts/hopperMemoryContracts.ts b/src/contracts/hopperMemoryContracts.ts index 4ca870e1ef..2a0020abef 100644 --- a/src/contracts/hopperMemoryContracts.ts +++ b/src/contracts/hopperMemoryContracts.ts @@ -11,7 +11,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ { name: "search_bytes", description: - "Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and never span blocks. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.", + "Find masked byte signatures in initialized analyzed memory. Hex byte pairs and ?? wildcards are supported; an optional equal-length hex mask selects bits. Bounds are inclusive. Matches are ordered by provider address and may span adjacent eligible initialized blocks; gaps and excluded blocks end a contiguous range. A match’s segment and file offset describe its starting address. Coverage describes this page; reuse the same query with next_cursor to continue. Uninitialized blocks are listed separately. Ghidra only; Hopper is unavailable.", inputSchema: z .strictObject({ ...bytePatternFields, @@ -26,7 +26,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ { name: "read_bytes", description: - "Read analyzed bytes from one provider-normalized virtual address. The hexadecimal payload reports the exact returned length; incomplete reads remain explicit and unsupported provider APIs return typed capability unavailability.", + "Read analyzed bytes from one provider-normalized virtual address. Ghidra reads initialized memory, which can include loader fixups; these bytes can differ from the original file. The hexadecimal payload reports the exact returned length; incomplete reads remain explicit and unsupported provider APIs return typed capability unavailability.", inputSchema: z.object({ address, length: z.int().min(1).default(256), @@ -36,7 +36,7 @@ export const HOPPER_MEMORY_TOOL_DEFINITIONS = [ { name: "address_to_file_offset", description: - "Map one provider-normalized virtual address to its original nonnegative file offset. Hopper verifies the original bytes against the selected executable digest and translates FAT image-relative offsets using the loaded header. Results preserve the provider coordinate, image base, and source path. Unmapped addresses fail explicitly; changed or unavailable sources return typed errors retaining native mapping facts as partial evidence.", + "Map one provider-normalized virtual address to its original nonnegative file offset. The mapping is for that address; it does not imply a contiguous file range or that analyzed memory bytes equal file bytes. Hopper verifies the original bytes against the selected executable digest and translates FAT image-relative offsets using the loaded header. Results preserve the provider coordinate, image base, and source path. Unmapped addresses fail explicitly; changed or unavailable sources return typed errors retaining native mapping facts as partial evidence.", inputSchema: z.object({ address, document }), }, ] as const; diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index eba10d04fa..0099a0c9f9 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -111,7 +111,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_elf_notes"), kind: "native-provider", description: - "Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.", + "Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields and Linux CORE process, mapping, signal, and auxv notes when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.", inputSchema: inspectElfNotesInputSchema, outputSchema: evidenceResultOf(elfNotesSchema), examples: [ @@ -245,7 +245,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_binary_layout"), kind: "native-provider", description: - "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Profile: EXEC/DYN/REL ELF64 x86-64, ELF64 little-endian AArch64, ELF32 i386, ELF32 little-endian ARM and ELF32 little- or big-endian MIPS on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success. Select detail summary to retain the complete layout Evidence in this session and receive only its summary view, then inspect sections, symbols, mitigations or linkage with inspect_analysis_view. pwntools_timeout_ms is each owned pwntools command's deadline (omitted means 30000 ms, range 1..2147483647, next call). cpuSeconds 30 and the output, diagnostic, and address-space byte allowances have no runtime override.", inputSchema: inspectBinaryLayoutRequestSchema, outputSchema: analysisDetailOutputSchemaOf( binaryLayoutSchema, diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 9d1ab1a899..e1da025271 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -21,7 +21,7 @@ import { HOPPER_MEMORY_TOOL_DEFINITIONS } from "./hopperMemoryContracts.js"; import { nativeSearchInput } from "../domain/native/nativeSearch.js"; const hopperRegexDeadline = - "Hopper regex mode uses ECMAScript Unicode syntax in a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum."; + "Regex dialect depends on the provider: Ghidra uses Java Pattern; Kuna, IDA and Hopper use ECMAScript Unicode. See binary_session for selected-provider limits. Hopper regex mode uses a cancellable worker. The matching deadline is REA_REGEX_MATCHING_DEADLINE_MS (a positive decimal integer of milliseconds from 1 to 2147483647, default 5000). Unset keeps the default. A deadline failure names the effective milliseconds, source (default or setting), the default, and the maximum, and leaves the API usable. The worker old-generation request is REA_REGEX_WORKER_HEAP_REQUEST_MIB (a decimal integer of mebibytes from 7 to 16384, default 128). Unset keeps the default. A positive Node --max-old-space-size, including NODE_OPTIONS, overrides that request and is not limited by the setting's floor or maximum. A heap failure names the effective request, its source (default or setting), the default, the floor, and the maximum."; const official = < Name extends keyof typeof officialOutputSchemas, @@ -224,7 +224,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "search_strings", - `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, + `Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. For known encoded bytes, Ghidra search_bytes can find byte matches; a hit does not establish a string or use. If an address is known, xrefs can look for references; read_bytes can inspect bytes where supported (not available with Kuna). Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`, z.object({ ...nativeSearchInput, detail: analysisDetailSchema }), { evidenceParameters: "omit-detail" }, ), @@ -260,7 +260,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "xrefs", - "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Use inspect_native_instruction at the target address for typed incoming references where supported.", + "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", z.object({ document, address: optionalAddress }), ), ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/reverseEngineeringToolContracts.ts b/src/contracts/reverseEngineeringToolContracts.ts index a0aa37f50e..d2aacf238b 100644 --- a/src/contracts/reverseEngineeringToolContracts.ts +++ b/src/contracts/reverseEngineeringToolContracts.ts @@ -17,7 +17,7 @@ export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [ ...toolContractMetadata("inspect_with_objdump"), kind: "native-provider", description: - "Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow GNU objdump. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap.", + "Run a caller-selected objdump command using GNU-style arguments against a local artifact, or request operation=architectures without a path to capture its response to -i. The executable implementation and version are not verified; other implementations may differ in options and output. For operation=disassemble, optional symbol selects native symbol-scoped disassembly before output collection; omission preserves all code sections. Matching and symbol boundaries follow the selected executable. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Default detail complete returns original Evidence. Explicit detail summary retains the complete parent and returns its command observation once, without the raw mirror; parent_evidence_id supports later inspect_analysis_view summary queries and complete Evidence export. Architecture output is subjectless and does not establish support for any artifact. Optional command_timeout_ms is the per-phase wall clock in milliseconds (default 120000, range 1..2147483647); omitted means 120000 and the value applies on the next call. It does not raise the 16 MiB output cap.", inputSchema: objdumpInputSchema, outputSchema: analysisDetailOutputSchemaOf( commandObservationSchema, diff --git a/src/contracts/sessionToolContracts.ts b/src/contracts/sessionToolContracts.ts index 87af0a7368..ddecb6226b 100644 --- a/src/contracts/sessionToolContracts.ts +++ b/src/contracts/sessionToolContracts.ts @@ -68,7 +68,7 @@ export const exportEvidenceBundleInputSchema = z.strictObject({ export const SESSION_TOOL_CONTRACTS = [ session( "open_binary", - "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.", + "Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Switching targets preserves investigation records. A failed admitted open reports the selection at failure in error.details.selection_at_failure; later calls can change it, so use binary_session for current state. Call close_binary to release engine resources; reset_investigation explicitly clears investigation records.", openBinaryInputSchema, ), session( @@ -98,7 +98,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "import_evidence_bundle", - "Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed.", + "Read the JSON bundle at the supplied local path, validate every Evidence ID and canonical manifest, then atomically merge it. Imported content is data only and is never executed. Import restores registry entries present in the bundle; questions embedded in Evidence do not automatically become Unknown registry entries.", importEvidenceBundleInputSchema, ), session( @@ -118,7 +118,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "match_native_functions", - "Match two retained list_procedures inventories by unique exact reported symbols, then unique identical observed function bytes. Optional same-context list_names/procedure_info/read_bytes/analyze_function Evidence supplies symbol origin, exact bodies, bytes and dossier links as supplied static facts. Optional compatible CFG/call/string fingerprints from supplied dossiers propose unresolved groups only. Duplicate candidates remain unresolved groups; only_left/right mean unpaired within supplied observations, never proof of addition/removal. Default summary retains complete Evidence for filtered, selected-column, paged function-matches views. Complete detail returns every group. Existing dossier pairs provide direct retained-reference compare_functions inputs.", + "Match two retained list_procedures inventories by unique exact reported symbols, then unique identical observed function bytes. Optional same-context list_names/procedure_info/read_bytes/analyze_function Evidence supplies symbol origin, exact bodies, bytes and dossier links as supplied static facts. Optional compatible CFG/call/string fingerprints from supplied dossiers propose unresolved groups only. Duplicate candidates remain unresolved groups; only_left/right mean unpaired within supplied observations, never proof of addition/removal. Default summary retains complete Evidence for filtered, selected-column, paged function-matches views. The byte-sources collection identifies selected read_bytes Evidence and its limitations; intersect its IDs with each function's evidence_links to identify its sources. Complete detail returns every group. Existing dossier pairs provide direct retained-reference compare_functions inputs.", matchNativeFunctionsInputSchema, ), session( @@ -153,7 +153,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "list_unknowns", - "List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct.", + "List current residual-unknown heads in deterministic ID order, with optional exact status, severity, and domain filters. Results are complete inline by default; supply offset and limit to page the filtered order. total is the matching-head count and next_offset is null when the page exhausts the result. This is read-only; unresolved, contradicted, and non-truth dispositions remain distinct. An empty registry does not mean retained Evidence has no residual questions; inspect the relevant Evidence or its analysis view.", listUnknownsInputSchema, ), session( @@ -173,7 +173,7 @@ export const SESSION_TOOL_CONTRACTS = [ ), session( "get_evidence_bundle", - "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary and records delivery.", + "Return every retained Evidence record and residual unknown as a complete canonical bundle by default. detail=summary discovers retained records without copying or transferring their payloads; optionally filter by exact Evidence ID, operation, target SHA-256, analysis-profile digest or canonical native procedure address. Metadata distinguishes complete record retention from native value-flow truncation or unavailable/unknown coverage. detail=records returns a page (offset, limit) of the matching complete records with unchanged IDs and their missing direct evidence_links; it is not an importable bundle. detail=links reads which evidence_links of the selected records this session or bundle retains, without their payloads: traversal retained-closure also follows retained links, the walk composed workflows require, and rows default to missing links. Use a discovered evidence_id with inspect_analysis_view without restarting a provider. Filters apply to summary, records and links delivery.", getEvidenceBundleInputSchema, ), { @@ -184,9 +184,12 @@ export const SESSION_TOOL_CONTRACTS = [ ), requiresActiveTarget: true, }, - session( - "inspect_address_context", - "Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input retains its canonical error and issues alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.", - addressContextInputSchema, - ), + { + ...session( + "inspect_address_context", + "Inspect one explicit reproducible address for its analyzed name, containing procedure, regular and inline comments, and matching bookmarks. Each unsupported facet returns a typed unavailable outcome. A facet rejected for invalid input retains its canonical error and issues alongside any successful facets. Use xrefs, assembly, or pseudocode for those deeper views.", + addressContextInputSchema, + ), + requiresActiveTarget: true, + }, ] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/sessionToolSchemas.ts b/src/contracts/sessionToolSchemas.ts index a8a0f31ada..25c344d790 100644 --- a/src/contracts/sessionToolSchemas.ts +++ b/src/contracts/sessionToolSchemas.ts @@ -5,26 +5,41 @@ import { bundleComparisonInputSchema } from "../domain/bundleComparison.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { + EVIDENCE_LINK_INSPECTION_LIMIT, EVIDENCE_RECORD_SELECTION_LIMIT, + evidenceLinkStatusFilterSchema, + evidenceLinkTraversalSchema, evidenceRecordFiltersSchema, } from "../domain/evidenceRecordSummary.js"; import { residualUnknownSchema } from "../domain/residualUnknown.js"; -/** Complete retained Evidence, selected discovery metadata, or a page of selected complete records. */ +/** Complete retained Evidence, discovery metadata, a page of complete records, or link membership. */ export const getEvidenceBundleInputSchema = z.strictObject({ - detail: z.enum(["complete", "summary", "records"]).default("complete"), + detail: z + .enum(["complete", "summary", "records", "links"]) + .default("complete"), filters: evidenceRecordFiltersSchema.optional(), offset: z .int() .nonnegative() .optional() - .describe("records only: first matching record to return; default 0."), + .describe("records or links: first matching row to return; default 0."), limit: z .int() .positive() .optional() .describe( - `records only: maximum complete records to return; default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}.`, + `records: maximum complete records, default ${String(EVIDENCE_RECORD_SELECTION_LIMIT)}. links: maximum link rows, default ${String(EVIDENCE_LINK_INSPECTION_LIMIT)}.`, + ), + traversal: evidenceLinkTraversalSchema + .optional() + .describe( + "links only: direct (default) reads the selected records' own links; retained-closure also follows retained links.", + ), + link_status: evidenceLinkStatusFilterSchema + .optional() + .describe( + "links only: rows to return, default missing. Counts always cover every inspected link.", ), }); diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index 669eb0a0e8..8d40f15230 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -1,7 +1,6 @@ import { nativeAddressListSchema, nativeObservationSourceSchemas, - traceContainingProcedureSchema, } from "../domain/native/nativeObservation.js"; import { overviewInventoryCoverageSchema } from "../domain/native/inventoryCoverage.js"; import { @@ -10,6 +9,7 @@ import { nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + nativeFeatureTraceSummaryViewResultSchema, batchDecompileSummaryViewResultSchema, nativeLoadImageSummaryViewResultSchema, artifactInventorySummaryViewResultSchema, @@ -18,7 +18,7 @@ import { managedArtifactSummaryViewResultSchema, managedMemberSummaryViewResultSchema, } from "../domain/analysisView/analysisView.js"; -import { literalFeatureMatchSchema } from "../domain/native/literalFeatureMatch.js"; +import { literalFeatureTraceSchema } from "../domain/native/literalFeatureTrace.js"; import { byteEntropySchema } from "../domain/native/byteEntropy.js"; import { nativeFunctionMatchingSchema } from "../domain/native/functionMatching.js"; import { nativeByteReadSchema } from "../domain/native/nativeByteRead.js"; @@ -46,6 +46,7 @@ import { jsonRecordSchema } from "../domain/jsonRecord.js"; import { byteSearchResultSchema } from "../domain/native/bytePattern.js"; import { evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, evidenceRecordSelectionSchema, } from "../domain/evidenceRecordSummary.js"; import { liveBinaryTargetSummaryVariants } from "../domain/liveBinaryTargetSummary.js"; @@ -262,21 +263,9 @@ export const officialOutputSchemas = { xrefs: evidenceResultOf(nativeAddressListSchema), } as const satisfies Readonly>; -const literalTraceOutput = evidenceResultOf( - z.object({ - query: z.string(), - search_mode: z.literal("literal"), - matches: z.array(literalFeatureMatchSchema), - references: z.array( - z.object({ - target_address: z.string(), - source_address: z.string(), - containing_procedure: traceContainingProcedureSchema, - }), - ), - truncated: z.boolean(), - residual_unknowns: z.array(z.string()), - }), +const literalTraceOutput = analysisDetailOutputSchemaOf( + literalFeatureTraceSchema, + nativeFeatureTraceSummaryViewResultSchema, ); const callPathTraceOutput = evidenceResultOf( @@ -537,6 +526,7 @@ export const sessionOutputSchemas = { evidenceBundleSchema, evidenceBundleSummarySchema, evidenceRecordSelectionSchema, + evidenceLinkInspectionSchema, ]), ), get_navigation_context: lifecycleResultOf( diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index cb0b73ba09..34b9c26f9c 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -54,6 +54,17 @@ export interface AnalysisCleanupObservation { readonly resources: readonly string[]; } +/** Publication stopped after a workflow produced its Evidence. Counts describe + * question occurrences, not distinct Unknown identities. */ +export interface WorkflowUnknownPublication { + readonly evidence_id: string; + readonly evidence_retention: "retained" | "unverified"; + readonly total_questions: number; + readonly created_questions: number; + readonly existing_questions: number; + readonly next_question_index: number; +} + /** * How long the adapter delivering a failure keeps REA's runtime and provider * session for its caller. An MCP connection continues: later calls reuse the @@ -162,6 +173,7 @@ export abstract class AnalysisError extends Error { readonly userCategory: "cancelled" | undefined = undefined; readonly executionFailure: string | undefined = undefined; partialObservation: AnalysisPartialObservation | undefined; + workflowUnknownPublication: WorkflowUnknownPublication | undefined; get cleanupPrimary(): AnalysisError | undefined { return this.#cleanupPrimary; @@ -306,6 +318,14 @@ export abstract class AnalysisError extends Error { this.partialObservation ??= observation; return this; } + + /** Preserve the exact committed prefix when automatic question publication stops. */ + retainWorkflowUnknownPublication( + publication: WorkflowUnknownPublication, + ): this { + this.workflowUnknownPublication ??= Object.freeze({ ...publication }); + return this; + } } /** Confirmed cleanup reveals the typed primary; unresolved ownership stays visible. */ diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 76a4e0ff40..2285303699 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -56,6 +56,10 @@ export const MCP_TRANSPORT_EVIDENCE_DISCOVERY = `Discover retained records with /** Next step when one MCP frame cannot carry a selected page of complete records. */ export const MCP_TRANSPORT_EVIDENCE_RECORDS = `Request fewer records with get_evidence_bundle detail: records, a smaller limit or an exact evidence_id filter; inspect compatible records in pages through inspect_analysis_view. A single record larger than one response needs export. ${MCP_TRANSPORT_EVIDENCE_EXPORT}`; +/** Next step when one MCP frame cannot carry a selected page of link rows. */ +export const MCP_TRANSPORT_EVIDENCE_LINKS = + "Request fewer rows with get_evidence_bundle detail: links, a smaller limit or an exact evidence_id filter, then follow next_offset."; + /** Name the next step that is true for how long the caller keeps REA's runtime and session. */ export const analysisErrorRemediationAction = ( error: AnalysisError, diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 921845a980..d85961b36e 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -33,7 +33,10 @@ import { } from "./hopperStartupFailure.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; import { UnknownRegistryError } from "./unknownRegistryError.js"; -import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { + childFailureDiagnostics, + projectAnalysisError, +} from "./analysisErrorProjection.js"; import { analysisErrorRemediationAction, analysisErrorUserMessage, @@ -48,6 +51,55 @@ import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js"; import type { AnalysisCallerLifetime } from "./analysisErrorBase.js"; import { ProviderCleanupError } from "./providerCleanupError.js"; +it("projects nested workflow-publication recovery for the actual caller lifetime", () => { + const child = new UnknownRegistryError( + "limit", + ).retainWorkflowUnknownPublication({ + evidence_id: `ev_${"a".repeat(64)}`, + evidence_retention: "retained", + total_questions: 3, + created_questions: 1, + existing_questions: 0, + next_question_index: 1, + }); + const error = new AnalysisCancelledError( + "trace_feature", + childFailureDiagnostics({ child: { error: child } }), + ); + const continuing = projectAnalysisError(error); + const invocation = projectAnalysisError(error, "invocation"); + const childOf = (value: typeof continuing) => { + const diagnostics = value.details?.diagnostics; + if ( + typeof diagnostics !== "object" || + diagnostics === null || + Array.isArray(diagnostics) + ) + throw new Error("Expected child diagnostics"); + return analysisErrorProjectionSchema.parse(diagnostics.child); + }; + expect( + childOf(continuing).details?.workflow_unknown_publication, + ).toMatchObject({ + evidence_retention: "retained", + created_questions: 1, + next_question_index: 1, + }); + expect(childOf(continuing).remediation.action).toContain( + "get_evidence_bundle", + ); + expect( + childOf(invocation).details?.workflow_unknown_publication, + ).toMatchObject({ + evidence_retention: "invocation-only", + created_questions: 1, + next_question_index: 1, + }); + expect(childOf(invocation).remediation.action).not.toContain( + "get_evidence_bundle", + ); +}); + describe("agent recovery metadata", () => { it("does not mark deterministic input validation as retryable", () => { const projected = projectAnalysisError( diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 9211830bdf..e00d7c486e 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -128,7 +128,8 @@ const projectionDependsOnCallerLifetime = (error: AnalysisError): boolean => { if ( node.limit !== undefined || node.continuingCallerGuidance !== undefined || - node.invocationDiagnostics !== undefined + node.invocationDiagnostics !== undefined || + node.workflowUnknownPublication !== undefined ) return true; return false; @@ -168,8 +169,22 @@ const projectError = ( const underlyingCode = underlyingErrorCode(primary); const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode; const primaryDetails = errorDetails(primary); + const publication = + composed.workflowUnknownPublication ?? primary.workflowUnknownPublication; const details = { ...primaryDetails, + ...(publication === undefined + ? {} + : { + workflow_unknown_publication: { + ...publication, + evidence_retention: + callerLifetime === "invocation" && + publication.evidence_retention === "retained" + ? "invocation-only" + : publication.evidence_retention, + }, + }), ...(primary.limit === undefined ? {} : { @@ -225,6 +240,11 @@ const projectError = ( ? errorRecovery(error) : cleanupRetryRecovery(cleanupRetry); const remediation = analysisErrorRemediationAction(error, callerLifetime); + const publicationRemediation = + callerLifetime === "continuing" && + publication?.evidence_retention === "retained" + ? " Use get_evidence_bundle with the Evidence ID in details.workflow_unknown_publication to retrieve the completed result; use list_unknowns to inspect recorded questions. Publication stopped at the reported index; later questions were not checked in this attempt." + : ""; return { code, category: analysisErrorCategory(error), @@ -239,8 +259,8 @@ const projectError = ( remediation: { action: cleanupRetry === undefined - ? remediation - : `${remediation} ${cleanupRetryRemediation(cleanupRetry)}`, + ? `${remediation}${publicationRemediation}` + : `${remediation} ${cleanupRetryRemediation(cleanupRetry)}${publicationRemediation}`, }, ...(recovery === undefined ? {} : { recovery }), ...(Object.keys(details).length === 0 ? {} : { details }), @@ -472,6 +492,9 @@ const requestErrorDetails = ( return { evidence_id: error.evidenceId, reason: error.reason, + ...(typeof error.diagnostics?.operation === "string" + ? { operation: error.diagnostics.operation } + : {}), ...(error.referenceScope === undefined ? {} : { reference_scope: error.referenceScope }), diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index 76cf22f7dc..624c3e3a36 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -462,7 +462,7 @@ describe("analysis snapshot canonical ordering and scope", () => { foreign, ]), }), - ).toThrow(/another target/u); + ).toThrow(/unrelated to the selected target/u); }); }); @@ -511,6 +511,61 @@ it("does not select arbitrary provenance when multiple authenticated records mat ).toBeUndefined(); }); +it("retains linked foreign Evidence without treating it as selected-target replay", () => { + const { current, snapshot } = snapshotWithHistoricalEvidence(); + const foreignTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: "b".repeat(64) }; + const foreign = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: current.operation, + parameters: current.parameters, + result: current.normalized_result, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + }); + const linked = createEvidence( + ANALYSIS_SNAPSHOT_TARGET, + ANALYSIS_SNAPSHOT_PROVIDER, + { + operation: current.operation, + parameters: current.parameters, + result: current.normalized_result, + analysisProfile: ANALYSIS_SNAPSHOT_PROFILE, + evidenceLinks: [foreign.evidence_id], + }, + ); + const bundle = createEvidenceBundle([ + ...snapshot.evidence_bundle.records.filter( + (record) => record.evidence_id !== current.evidence_id, + ), + linked, + foreign, + ]); + const admitted = parseAnalysisSnapshot({ + ...snapshot, + evidence_bundle: bundle, + }); + expect( + snapshotEvidenceForQuery(admitted, { + target: ANALYSIS_SNAPSHOT_TARGET, + bindingProfile: ANALYSIS_SNAPSHOT_PROFILE, + operation: linked.operation, + parameters: linked.parameters, + provider: ANALYSIS_SNAPSHOT_PROVIDER, + evidenceProfile: ANALYSIS_SNAPSHOT_PROFILE, + }), + ).toEqual(linked); + expect(snapshotMatchesTarget(admitted.target, foreignTarget)).toBe(false); + const unrelated = createEvidence(foreignTarget, ANALYSIS_SNAPSHOT_PROVIDER, { + operation: "unrelated", + parameters: {}, + result: "foreign", + }); + expect(() => + parseAnalysisSnapshot({ + ...snapshot, + evidence_bundle: createEvidenceBundle([...bundle.records, unrelated]), + }), + ).toThrow("unrelated to the selected target and its linked sources"); +}); + describe("analysis snapshot Evidence binding", () => { it("finds only Evidence committed to the exact binding and profile", () => { const { current, snapshot } = snapshotWithHistoricalEvidence(); diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 7aba9dd72f..42d4894415 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -76,6 +76,7 @@ const formatSchema = z.enum([ "ines", "godot-pck", "unityfs", + "wxapkg", "dmg", "pkg", "intel-hex", @@ -189,6 +190,27 @@ export type AnalysisSnapshotWorkflowEntry = z.infer; export type AnalysisSnapshotTarget = z.infer; export type AnalysisSnapshotBinding = z.infer; +/** Recognize an already-parsed JSON file envelope; this does not validate or authenticate a snapshot. */ +export const resemblesAnalysisSnapshot = (value: unknown): boolean => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return false; + const record = value as Record; + const objectMember = (key: string) => + Object.hasOwn(record, key) && + typeof record[key] === "object" && + record[key] !== null && + !Array.isArray(record[key]); + return ( + objectMember("target") && + objectMember("binding") && + objectMember("evidence_bundle") && + Object.hasOwn(record, "entries") && + Array.isArray(record.entries) && + Object.hasOwn(record, "workflow_entries") && + Array.isArray(record.workflow_entries) + ); +}; + // These handles only represent entries parsed in this realm. File readers can // release their payloads while retaining an authenticated comparison operand. const preparedEntryBrand = Symbol("prepared-snapshot-entry"); @@ -738,7 +760,7 @@ const restoreSnapshot = ( scopedEvidence.unknowns.length !== bundle.unknowns.length ) throw new TypeError( - "Analysis snapshot evidence contains records for another target", + "Analysis snapshot evidence contains records unrelated to the selected target and its linked sources", ); if (!hasCanonicalQueryOrder(providerRows.map(({ entry }) => entry))) throw new TypeError("Analysis snapshot entries are not canonical"); diff --git a/src/domain/analysisSource.ts b/src/domain/analysisSource.ts new file mode 100644 index 0000000000..648859a5c1 --- /dev/null +++ b/src/domain/analysisSource.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { digestSchema } from "./digests.js"; +import { absoluteLocalPathStringSchema } from "./localPath.js"; + +/** Compact source facts supplied by the provider for one observation. */ +export const analysisSourceDescriptorSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("file-range"), + path: absoluteLocalPathStringSchema, + sha256: digestSchema, + byte_offset: z.int().nonnegative(), + byte_length: z.int().nonnegative(), + }), + z.strictObject({ kind: z.literal("single-function-decompile") }), +]); + +export type AnalysisSourceDescriptor = z.infer< + typeof analysisSourceDescriptorSchema +>; diff --git a/src/domain/analysisView/analysisView.ts b/src/domain/analysisView/analysisView.ts index 91937ee723..8d8af4aba6 100644 --- a/src/domain/analysisView/analysisView.ts +++ b/src/domain/analysisView/analysisView.ts @@ -3,6 +3,7 @@ import { NATIVE_OBSERVATION_OPERATIONS, } from "../native/nativeObservation.js"; import { projectNativeObservationView } from "./nativeObservationView.js"; +import { projectNativeValueTraceView } from "./nativeValueTraceView.js"; import { projectDevicetreeView } from "./devicetreeView.js"; import { projectApkSignatureView } from "./apkSignatureView.js"; import { byteEntropySchema } from "../native/byteEntropy.js"; @@ -18,6 +19,7 @@ import { projectJavaScriptRoutesView } from "./javascriptRoutesView.js"; import { projectWebBundleView } from "./webBundleView.js"; import { projectSourceToBundleView } from "./sourceToBundleView.js"; import { projectFeatureTraceView } from "./featureTraceView.js"; +import { projectNativeFeatureTraceView } from "./nativeFeatureTraceView.js"; import { projectBatchDecompileView } from "./batchDecompileView.js"; import { projectGpuCodeObjectsView } from "./gpuCodeObjectsView.js"; import { projectModelArtifactView } from "./modelArtifactView.js"; @@ -144,6 +146,7 @@ import { modelArtifactPageViewResultSchema, il2cppMetadataSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + nativeFeatureTraceSummaryViewResultSchema, nativeLoadImageSummaryViewResultSchema, nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, @@ -165,6 +168,7 @@ export { }; export { nativeObservationSummaryViewResultSchemas, + nativeFeatureTraceSummaryViewResultSchema, byteEntropySummaryViewResultSchema, nativeExportsSummaryViewResultSchema, devicetreeSummaryViewResultSchema, @@ -300,9 +304,11 @@ const retain = ( ): readonly [string, RetainedViewProjector] => [operation, project]; const retainedViewProjectors = new Map([ + retain("trace_feature", projectNativeFeatureTraceView), ...NATIVE_OBSERVATION_OPERATIONS.map((operation) => retain(operation, projectNativeObservationView), ), + retain("trace_native_values", projectNativeValueTraceView), retain("inspect_erlang_beam", projectErlangBeamView), retain("inspect_devicetree", projectDevicetreeView), retain("inspect_native_exports", projectNativeExportsView), @@ -436,6 +442,7 @@ export const projectAnalysisView = ( parent.operation === "match_native_functions" ? NATIVE_FUNCTION_MATCHING_PREDICATE : parent.operation === "analyze_function" || + parent.operation === "trace_feature" || parent.operation === "batch_decompile" || isNativeObservationOperation(parent.operation) || isNativeInventoryOperation(parent.operation) diff --git a/src/domain/analysisView/analysisViewCoverage.ts b/src/domain/analysisView/analysisViewCoverage.ts index 392c711095..d5f694c0fd 100644 --- a/src/domain/analysisView/analysisViewCoverage.ts +++ b/src/domain/analysisView/analysisViewCoverage.ts @@ -9,8 +9,33 @@ export const analysisViewCoverageSchema = z.strictObject({ exhausted: z.boolean(), }); +const unavailableViewCoverageSchema = z.strictObject({ + status: z.literal("unavailable"), + examined: z.literal(0), + total: z.null(), + next_offset: z.null(), + exhausted: z.literal(false), +}); + +/** Only views with nullable collections can report unavailable page coverage. */ +export const nullableCollectionViewCoverageSchema = z.union([ + analysisViewCoverageSchema, + unavailableViewCoverageSchema, +]); + export type AnalysisViewCoverage = z.output; +/** Unrecorded or inapplicable collections have no known total or continuation. */ +export const unavailableViewCoverage = (): z.output< + typeof unavailableViewCoverageSchema +> => ({ + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, +}); + /** Coverage for a summary, facet, or single selected object. */ export const completeWithinViewCoverage = ( examined: number, diff --git a/src/domain/analysisView/analysisViewRequest.ts b/src/domain/analysisView/analysisViewRequest.ts index cfa24c93f6..5a8920d4b7 100644 --- a/src/domain/analysisView/analysisViewRequest.ts +++ b/src/domain/analysisView/analysisViewRequest.ts @@ -11,6 +11,7 @@ import { javaScriptRoutesViewRequestSchema } from "./javascriptRoutesView.js"; import { webBundleViewRequestSchema } from "./webBundleView.js"; import { sourceToBundleViewRequestSchema } from "./sourceToBundleView.js"; import { featureTraceViewRequestSchema } from "./featureTraceView.js"; +import { nativeFeatureTraceViewRequestSchema } from "./nativeFeatureTraceView.js"; import { batchDecompileViewRequestSchema } from "./batchDecompileView.js"; import { gpuCodeObjectsViewRequestSchema } from "./gpuCodeObjectsView.js"; import { modelArtifactViewRequestSchema } from "../model/modelArtifactQuery.js"; @@ -31,6 +32,7 @@ import { desktopViewRequestSchema } from "./desktopApplicationView.js"; import { javascriptModuleReferencesRequestSchema } from "./javascriptModuleReferences.js"; import { javascriptFactsViewRequestSchema } from "./javascriptFactsView.js"; import { evidenceSchema } from "../evidence.js"; +import { declareEvidenceReferenceAlternative } from "../evidenceInputContract.js"; import { apiSurfaceViewSchema } from "./webNetworkApiSurfaceView.js"; import { evmViewRequestSchema } from "./evmInterfaceView.js"; import { artifactInventoryViewRequestSchema } from "./artifactInventoryView.js"; @@ -69,6 +71,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [ nativeFunctionMatchingViewRequestSchema, sourceToBundleViewRequestSchema, featureTraceViewRequestSchema, + nativeFeatureTraceViewRequestSchema, batchDecompileViewRequestSchema, summaryViewSchema, goBinaryViewRequestSchema, @@ -115,14 +118,16 @@ inlineEvidenceInputSchema._zod.processJSONSchema = evidenceSchema._zod.processJSONSchema; /** Unauthenticated inline input, an exact same-session retained reference, or one local Evidence file. */ -export const analysisViewSourceSchema = z.discriminatedUnion("kind", [ - retainedEvidenceReferenceSchema, - z.strictObject({ - kind: z.literal("inline"), - evidence: inlineEvidenceInputSchema, - }), - evidenceFileReferenceSchema, -]); +export const analysisViewSourceSchema = declareEvidenceReferenceAlternative( + z.discriminatedUnion("kind", [ + retainedEvidenceReferenceSchema, + z.strictObject({ + kind: z.literal("inline"), + evidence: inlineEvidenceInputSchema, + }), + evidenceFileReferenceSchema, + ]), +); /** Public inspect_analysis_view input. */ export const inspectAnalysisViewInputSchema = z.strictObject({ diff --git a/src/domain/analysisView/analysisViewResult.ts b/src/domain/analysisView/analysisViewResult.ts index 98328d9b2c..797f5d5fae 100644 --- a/src/domain/analysisView/analysisViewResult.ts +++ b/src/domain/analysisView/analysisViewResult.ts @@ -1,4 +1,7 @@ -import { analysisViewCoverageSchema } from "./analysisViewCoverage.js"; +import { + analysisViewCoverageSchema, + nullableCollectionViewCoverageSchema, +} from "./analysisViewCoverage.js"; import { javaScriptRouteInventorySourceSchema, javaScriptRouteInventorySchema, @@ -16,6 +19,7 @@ import { NATIVE_OBSERVATION_OPERATIONS, type NativeObservationOperation, } from "../native/nativeObservation.js"; +import { nativeValueTraceSummarySchema } from "./nativeValueTraceView.js"; import { devicetreeViewRequestSchema, devicetreeViewItemsSchema, @@ -68,6 +72,11 @@ import { featureTraceViewCollections, featureTraceViewContextSchema, } from "./featureTraceView.js"; +import { + nativeFeatureTraceSummarySchema, + nativeFeatureTraceViewItems, + nativeFeatureTraceRequests, +} from "./nativeFeatureTraceView.js"; import { batchDecompileSummarySchema, batchDecompileViewCollections, @@ -348,6 +357,7 @@ const viewResultBase = { "inspect_gpu_code_objects", ...NATIVE_INVENTORY_OPERATIONS, ...NATIVE_OBSERVATION_OPERATIONS, + "trace_native_values", "match_native_functions", ]), parent_digest: digestSchema, @@ -382,17 +392,27 @@ export const nativeObservationSummaryViewResultSchemas = { procedure_address: nativeObservationSummaryFor("procedure_address"), procedure_callees: nativeObservationSummaryFor("procedure_callees"), procedure_callers: nativeObservationSummaryFor("procedure_callers"), + xrefs: nativeObservationSummaryFor("xrefs"), resolve_containing_procedure: nativeObservationSummaryFor( "resolve_containing_procedure", ), get_call_graph: nativeObservationSummaryFor("get_call_graph"), }; + +export const nativeValueTraceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + artifact: artifactSchema.nullable(), + parent_operation: z.literal("trace_native_values"), + summary: nativeValueTraceSummarySchema, +}); + const nativeObservationSummaryViewResultSchema = z.discriminatedUnion( "parent_operation", [ nativeObservationSummaryViewResultSchemas.procedure_address, nativeObservationSummaryViewResultSchemas.procedure_callees, nativeObservationSummaryViewResultSchemas.procedure_callers, + nativeObservationSummaryViewResultSchemas.xrefs, nativeObservationSummaryViewResultSchemas.resolve_containing_procedure, nativeObservationSummaryViewResultSchemas.get_call_graph, ], @@ -412,6 +432,7 @@ const nativeObservationPageViewResultSchema = z.discriminatedUnion( nativeObservationPageFor("procedure_address"), nativeObservationPageFor("procedure_callees"), nativeObservationPageFor("procedure_callers"), + nativeObservationPageFor("xrefs"), nativeObservationPageFor("resolve_containing_procedure"), nativeObservationPageFor("get_call_graph"), ], @@ -434,7 +455,9 @@ const nativeContextViewResultSchema = z.discriminatedUnion("facet", [ nativeContextFor("overview"), nativeContextFor("analysis-profile"), nativeContextFor("limitations"), - nativeContextFor("result-limitations"), + nativeContextFor("result-limitations").extend({ + coverage: nullableCollectionViewCoverageSchema, + }), ]); export const byteEntropySummaryViewResultSchema = z.strictObject({ @@ -830,6 +853,40 @@ export const nativeInventorySummaryViewResultSchema = z.strictObject({ summary: nativeInventorySummarySchema, }); +export const nativeFeatureTraceSummaryViewResultSchema = z.strictObject({ + ...summaryViewResultBase, + ...nativeViewSourceContext, + artifact: artifactSchema.nullable(), + parent_operation: z.literal("trace_feature"), + summary: nativeFeatureTraceSummarySchema, +}); + +const nativeFeatureTracePage = < + Collection extends keyof typeof nativeFeatureTraceViewItems, +>( + collection: Collection, +) => + z.strictObject({ + ...viewResultBase, + ...nativeViewSourceContext, + artifact: artifactSchema.nullable(), + kind: z.literal("native-feature-trace"), + parent_operation: z.literal("trace_feature"), + view: nativeFeatureTraceRequests[collection], + summary: nativeFeatureTraceSummarySchema, + items: nativeFeatureTraceViewItems[collection], + }); +const nativeFeatureTracePageViewResultSchema = z + .object({ kind: z.literal("native-feature-trace") }) + .passthrough() + .pipe( + z.union([ + nativeFeatureTracePage("matches"), + nativeFeatureTracePage("references"), + nativeFeatureTracePage("unknowns"), + ]), + ); + /** Compact provider load observations retain the independent verification state. */ export const nativeLoadImageSummaryViewResultSchema = z.strictObject({ ...summaryViewResultBase, @@ -1072,6 +1129,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ artifact: z.null(), view: nativeFunctionMatchingViewRequestSchema, items: nativeFunctionMatchingViewItemsSchema, + byte_limited: z.boolean().optional(), parent_operation: z.literal("match_native_functions"), }), z.strictObject({ @@ -1117,6 +1175,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ }), z.discriminatedUnion("parent_operation", [ nativeObservationSummaryViewResultSchema, + nativeValueTraceSummaryViewResultSchema, erlangBeamSummaryViewResultSchema, devicetreeSummaryViewResultSchema, pythonPycSummaryViewResultSchema, @@ -1150,6 +1209,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ modelArtifactSummaryViewResultSchema, gpuCodeObjectsSummaryViewResultSchema, nativeInventorySummaryViewResultSchema, + nativeFeatureTraceSummaryViewResultSchema, nativeLoadImageSummaryViewResultSchema, nativeFunctionMatchingSummaryViewResultSchema, nativeFunctionSummaryViewResultSchema, @@ -1185,9 +1245,11 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [ view: nativeInventoryViewRequestSchema, rows: z.array(nativeInventoryViewRowSchema), }), + nativeFeatureTracePageViewResultSchema, z.strictObject({ ...viewResultBase, kind: z.literal("native-load-image"), + coverage: nullableCollectionViewCoverageSchema, ...nativeViewSourceContext, verification_status: z.enum(["unsupported", "verified", "mismatch"]), parent_operation: z.literal("inspect_native_load_image"), diff --git a/src/domain/analysisView/artifactInventoryView.ts b/src/domain/analysisView/artifactInventoryView.ts index 50fa587ce1..cd368e68a3 100644 --- a/src/domain/analysisView/artifactInventoryView.ts +++ b/src/domain/analysisView/artifactInventoryView.ts @@ -277,6 +277,7 @@ export const projectArtifactInventoryView = ( view.kind === "inventory" || inventory.manifest.root_format === "godot-pck" || inventory.manifest.root_format === "unityfs" || + inventory.manifest.root_format === "wxapkg" || inventory.manifest.root_format === "pyinstaller" || inventory.manifest.root_format === "node-sea" || inventory.manifest.root_format === "intel-hex" || diff --git a/src/domain/analysisView/batchDecompileView.ts b/src/domain/analysisView/batchDecompileView.ts index 19aae24827..29ee452596 100644 --- a/src/domain/analysisView/batchDecompileView.ts +++ b/src/domain/analysisView/batchDecompileView.ts @@ -65,7 +65,7 @@ const itemsRequestSchema = z.strictObject({ "Exact observed canonical entries. An item with unknown procedure identity never matches. Omitted selects all; [] selects none.", ), statuses: z - .array(z.enum(["ok", "error"])) + .array(z.enum(["ok", "unavailable", "error"])) .optional() .describe("Item outcomes. Omitted selects all; [] selects none."), }) @@ -115,6 +115,12 @@ const batchDecompileRowSchema = z.discriminatedUnion("status", [ pseudocode_code_units: z.int().positive(), pseudocode: z.string().min(1).exactOptional(), }), + z.strictObject({ + ...itemIdentity, + status: z.literal("unavailable"), + reason: z.literal("no_pseudocode"), + limitations: batchItemLimitationsSchema, + }), z.strictObject({ ...itemIdentity, status: z.literal("error"), @@ -271,6 +277,15 @@ const itemRow = ( pseudocode_code_units: item.pseudocode.length, ...(includePseudocode ? { pseudocode: item.pseudocode } : {}), }; + if (item.status === "unavailable") + return { + ...identity, + status: "unavailable", + reason: item.reason, + ...(item.limitations === undefined + ? {} + : { limitations: item.limitations }), + }; return { ...identity, status: "error", @@ -375,7 +390,7 @@ export const projectBatchDecompileView = ( ); if (item.status !== "ok") return pseudocodeRefusal( - `Item ${String(view.index)} has status error and no pseudocode; read its error and limitations through the items collection.`, + `Item ${String(view.index)} has status ${item.status} and no pseudocode; read its reason or error and limitations through the items collection.`, ); const page = analysisTextPage(item.pseudocode, view.offset, view.limit); if (!page.ok) return page; diff --git a/src/domain/analysisView/nativeFeatureTraceView.ts b/src/domain/analysisView/nativeFeatureTraceView.ts new file mode 100644 index 0000000000..edabccf2d7 --- /dev/null +++ b/src/domain/analysisView/nativeFeatureTraceView.ts @@ -0,0 +1,224 @@ +import { z } from "zod"; +import { literalFeatureMatchSchema } from "../native/literalFeatureMatch.js"; +import { + literalFeatureReferenceSchema, + literalFeatureTraceSchema, +} from "../native/literalFeatureTrace.js"; +import { jsonValueSchema } from "../jsonValue.js"; +import { err, ok } from "../result.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { selectViewRows } from "./selectViewRows.js"; +import { + createAnalysisViewResultReader, + type AnalysisViewParent, +} from "./analysisViewParent.js"; +import { + assertOwnedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, + pageViewCoverage, +} from "./analysisViewCoverage.js"; + +const page = { + kind: z.literal("native-feature-trace"), + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), +}; +export const nativeFeatureTraceRequests = { + matches: z.strictObject({ + ...page, + collection: z.literal("matches"), + address: z + .string() + .min(1) + .optional() + .describe("Select rows with this exact retained address."), + }), + references: z.strictObject({ + ...page, + collection: z.literal("references"), + source_address: z + .string() + .min(1) + .optional() + .describe("Select this exact retained source address."), + target_address: z + .string() + .min(1) + .optional() + .describe("Select this exact retained target address."), + }), + unknowns: z.strictObject({ ...page, collection: z.literal("unknowns") }), +} as const; +export const nativeFeatureTraceViewRequestSchema = z.discriminatedUnion( + "collection", + [ + nativeFeatureTraceRequests.matches, + nativeFeatureTraceRequests.references, + nativeFeatureTraceRequests.unknowns, + ], +); + +const row = (value: T) => + z.strictObject({ source_index: z.int().nonnegative(), value }); +export const nativeFeatureTraceViewItems = { + matches: z.strictObject({ + collection: z.literal("matches"), + rows: z.array(row(literalFeatureMatchSchema)), + }), + references: z.strictObject({ + collection: z.literal("references"), + rows: z.array(row(literalFeatureReferenceSchema)), + }), + unknowns: z.strictObject({ + collection: z.literal("unknowns"), + rows: z.array(row(z.string())), + }), +} as const; +export const nativeFeatureTraceSummarySchema = z.strictObject({ + query: z.string(), + search_mode: z.literal("literal"), + matches: z.int().nonnegative(), + references: z.int().nonnegative(), + unknowns: z.int().nonnegative(), + truncated: z.boolean(), +}); + +const readTrace = createAnalysisViewResultReader(literalFeatureTraceSchema); + +/** Page retained trace rows without collapsing independently resolvable unknowns. */ +export const projectNativeFeatureTraceView = ( + parent: AnalysisViewParent, + view: AdmittedView, +) => { + if (view.kind !== "summary" && view.kind !== "native-feature-trace") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Native feature traces support summary and native-feature-trace views.", + }, + ]), + ); + assertOwnedView< + SummaryAnalysisView | z.output + >(view, "summary", "native-feature-trace"); + const parsed = readTrace(parent); + if (!parsed.ok) return parsed; + const trace = parsed.value; + const shared = { + parent_evidence_id: parent.evidenceId, + parent_operation: "trace_feature" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact ?? null, + source_provider: parent.provider ?? null, + source_analysis_profile_digest: parent.analysisProfile?.digest ?? null, + source_parameters: jsonValueSchema.parse(parent.parameters ?? null), + limitations: boundedViewMessages( + parent.limitations, + parent.evidenceId, + "limitations", + ), + unknowns: [ + "Page coverage describes retained trace rows, not completeness of provider discovery. See the retained unknowns and source Evidence for limitations.", + ], + summary: { + query: trace.query, + search_mode: trace.search_mode, + matches: trace.matches.length, + references: trace.references.length, + unknowns: trace.residual_unknowns.length, + truncated: trace.truncated, + }, + }; + if (view.kind === "summary") + return ok({ + ...shared, + kind: "summary" as const, + view, + coverage: completeWithinViewCoverage( + trace.matches.length + + trace.references.length + + trace.residual_unknowns.length, + trace.matches.length + + trace.references.length + + trace.residual_unknowns.length, + ), + }); + const select = ( + values: readonly Value[], + matches?: (value: Value) => boolean, + ) => { + if (matches === undefined) { + const rows = values + .slice(view.offset, view.offset + view.limit) + .map((value, index) => ({ source_index: view.offset + index, value })); + return { + rows, + coverage: pageViewCoverage(view.offset, rows.length, values.length), + }; + } + const { rows, total } = selectViewRows( + values, + view, + matches, + (value, source_index) => ({ source_index, value }), + ); + return { + rows, + coverage: pageViewCoverage(view.offset, rows.length, total), + }; + }; + switch (view.collection) { + case "matches": { + const { rows, coverage } = select( + trace.matches, + view.address === undefined + ? undefined + : (row) => row.address === view.address, + ); + return ok({ + ...shared, + kind: "native-feature-trace" as const, + view: { ...view, collection: "matches" as const }, + items: { collection: "matches" as const, rows }, + coverage, + }); + } + case "references": { + const { rows, coverage } = select( + trace.references, + view.source_address === undefined && view.target_address === undefined + ? undefined + : (row) => + (view.source_address === undefined || + row.source_address === view.source_address) && + (view.target_address === undefined || + row.target_address === view.target_address), + ); + return ok({ + ...shared, + kind: "native-feature-trace" as const, + view: { ...view, collection: "references" as const }, + items: { collection: "references" as const, rows }, + coverage, + }); + } + case "unknowns": { + const { rows, coverage } = select(trace.residual_unknowns); + return ok({ + ...shared, + kind: "native-feature-trace" as const, + view: { ...view, collection: "unknowns" as const }, + items: { collection: "unknowns" as const, rows }, + coverage, + }); + } + } +}; diff --git a/src/domain/analysisView/nativeFunctionMatchingView.ts b/src/domain/analysisView/nativeFunctionMatchingView.ts index 4d5d8cfe3d..acf0d95081 100644 --- a/src/domain/analysisView/nativeFunctionMatchingView.ts +++ b/src/domain/analysisView/nativeFunctionMatchingView.ts @@ -3,6 +3,8 @@ import { nativeFunctionMatchingSchema, nativeFunctionMatchSchema, nativeFunctionMatchMemberSchema, + nativeFunctionByteSourceSchema, + nativeFunctionByteSourceGuidance, nativeMatchStateSchema, type NativeFunctionMatching, } from "../native/functionMatching.js"; @@ -23,6 +25,8 @@ import { type SummaryAnalysisView, } from "./analysisViewShape.js"; import type { JsonValue } from "../jsonValue.js"; +import { jsonValueByteLength } from "../jsonValueByteLength.js"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; const matchRow = nativeFunctionMatchSchema.extend({ left_count: z.number().int().nonnegative(), @@ -56,9 +60,18 @@ export const nativeFunctionMatchingViewRequestSchema = z.discriminatedUnion( names: z.array(z.string()).optional(), fields: z.array(functionRow.keyof()).optional(), }), + z.strictObject({ + kind: z.literal("function-matches"), + collection: z.literal("byte-sources"), + sides: z.array(z.enum(["left", "right"])).optional(), + evidence_ids: z.array(z.string()).optional(), + offset: z.number().int().nonnegative().default(0), + limit: z.number().int().positive().default(64), + }), ], ); export const nativeFunctionMatchingViewItemsSchema = z.union([ + z.array(nativeFunctionByteSourceSchema), z.array( matchRow.partial().required({ match_id: true, @@ -93,8 +106,14 @@ interface Checked { } const checked = new WeakMap(); const readReport = createAnalysisViewResultReader(nativeFunctionMatchingSchema); +// Leave room for the Evidence envelope and duplicated MCP text/structured output. +const BYTE_SOURCE_PAGE_BYTES = 1024 * 1024; +const BYTE_SOURCE_PROJECTION_BYTES = + BYTE_SOURCE_PAGE_BYTES - + (jsonValueByteLength({ view_digest: "0".repeat(64) }) - 1); type Request = z.output; +type MatchRequest = Exclude; const checkedReport = ( parent: AnalysisViewParent, ): Result => { @@ -134,6 +153,7 @@ const sharedContext = ( [...parent.limitations, ...report.limitations], parent.evidenceId, "limitations", + report.byte_sources_count ? [nativeFunctionByteSourceGuidance] : [], ), unknowns: [ "Unpaired observations and page exhaustion do not establish binary-wide function absence or behavioral equivalence.", @@ -141,7 +161,7 @@ const sharedContext = ( }); const selected = (values: readonly string[] | undefined) => values === undefined ? undefined : new Set(values); -const filterRows = (entry: Checked, view: Request): readonly number[] => { +const filterRows = (entry: Checked, view: MatchRequest): readonly number[] => { const key = JSON.stringify([ view.collection, view.states, @@ -188,7 +208,7 @@ const filterRows = (entry: Checked, view: Request): readonly number[] => { }; const selectedItems = ( entry: Checked, - view: Request, + view: MatchRequest, rows: readonly number[], ) => { const indices = rows.slice(view.offset, view.offset + view.limit); @@ -283,6 +303,7 @@ export const projectNativeFunctionMatchingView = ( const { matches: _matches, limitations: _limitations, + byte_sources: _byteSources, ...context } = entry.report; const shared = sharedContext(parent, entry.report); @@ -294,6 +315,102 @@ export const projectNativeFunctionMatchingView = ( summary: context, coverage: completeWithinViewCoverage(1, 1), }); + if (view.collection === "byte-sources") { + if (entry.report.byte_sources === undefined) + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "This historical report did not record byte-source qualifications; inspect its linked Evidence.", + }, + ]), + ); + const sides = selected(view.sides), + ids = selected(view.evidence_ids); + const items: z.output[] = []; + const fixed = { + kind: "function-matches" as const, + view, + ...shared, + ...context, + }; + let bytes = jsonValueByteLength( + { + ...fixed, + items: [], + byte_limited: true, + coverage: pageViewCoverage( + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER, + ), + } as JsonValue, + BYTE_SOURCE_PROJECTION_BYTES, + ); + const tooLarge = (source?: { evidence_id: string; next_offset: number }) => + err( + new AnalysisResourceConstraintError( + "inspect_analysis_view", + "transport", + "The selected byte-source view exceeds the projection allowance.", + { + parent_evidence_id: parent.evidenceId, + limit_bytes: BYTE_SOURCE_PAGE_BYTES, + ...(source === undefined + ? {} + : { + source_evidence_id: source.evidence_id, + next_offset: source.next_offset, + }), + }, + { + retryable: false, + remediationAction: + source === undefined + ? "Narrow the selection or inspect the original matching Evidence." + : "Inspect the original matching Evidence, or export it if retained; use next_offset to continue after the oversized source.", + }, + ), + ); + if (bytes > BYTE_SOURCE_PROJECTION_BYTES) return tooLarge(); + let total = 0, + byteLimited = false; + for (const row of entry.report.byte_sources) { + if ( + (sides !== undefined && !sides.has(row.side)) || + (ids !== undefined && !ids.has(row.evidence_id)) + ) + continue; + if (total >= view.offset && items.length < view.limit && !byteLimited) { + const item = row; + const size = + jsonValueByteLength( + item, + Math.max(0, BYTE_SOURCE_PROJECTION_BYTES - bytes), + ) + 1; + if (bytes + size > BYTE_SOURCE_PROJECTION_BYTES) { + if (items.length === 0) + return tooLarge({ + evidence_id: row.evidence_id, + next_offset: total + 1, + }); + byteLimited = true; + } else { + items.push(item); + bytes += size; + } + } + total++; + } + return ok({ + ...fixed, + items, + byte_limited: byteLimited, + coverage: pageViewCoverage(view.offset, items.length, total), + }); + } const rows = filterRows(entry, view), items = selectedItems(entry, view, rows); return ok({ diff --git a/src/domain/analysisView/nativeLoadImageView.ts b/src/domain/analysisView/nativeLoadImageView.ts index eb5b56af88..594d9d688e 100644 --- a/src/domain/analysisView/nativeLoadImageView.ts +++ b/src/domain/analysisView/nativeLoadImageView.ts @@ -23,6 +23,7 @@ import { boundedViewMessages, completeWithinViewCoverage, pageViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; const observation = nativeLoadImageObservationSchema.shape; @@ -290,12 +291,12 @@ export const projectNativeLoadImageView = ( ? report.sections : undefined : observed[view.collection]; - const page = selectRows(selected ?? [], view); + const page = selected === undefined ? undefined : selectRows(selected, view); const symbolUnrecorded = view.collection === "relocations" && (view.fields === undefined || view.fields.some((field: string) => field === "symbol")) && - page.rows.some(({ record }) => !("symbol" in record)); + (page?.rows.some(({ record }) => !("symbol" in record)) ?? false); return ok({ ...shared, unknowns: symbolUnrecorded @@ -309,8 +310,11 @@ export const projectNativeLoadImageView = ( view, items: nativeLoadImageViewItemsSchema.parse({ collection: view.collection, - rows: selected === undefined ? null : page.rows, + rows: page?.rows ?? null, }), - coverage: pageViewCoverage(view.offset, page.rows.length, page.total), + coverage: + page === undefined + ? unavailableViewCoverage() + : pageViewCoverage(view.offset, page.rows.length, page.total), }); }; diff --git a/src/domain/analysisView/nativeObservationSchemas.ts b/src/domain/analysisView/nativeObservationSchemas.ts index 50f42c6c99..bd526d2379 100644 --- a/src/domain/analysisView/nativeObservationSchemas.ts +++ b/src/domain/analysisView/nativeObservationSchemas.ts @@ -158,6 +158,7 @@ export const nativeObservationItemsSchemas = { procedure_address: addresses, procedure_callees: addresses, procedure_callers: addresses, + xrefs: addresses, resolve_containing_procedure: resolutions, get_call_graph: nativeGraphViewItemsSchema, }; diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index a532242c2d..ee4530adc3 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -20,7 +20,7 @@ import { createAnalysisViewResultReader } from "./analysisViewParent.js"; import { analysisViewInputError } from "./analysisViewErrors.js"; import { pageViewCoverage, - completeWithinViewCoverage, + unavailableViewCoverage, } from "./analysisViewCoverage.js"; import { nativeContextViewRequestSchema, @@ -45,7 +45,8 @@ const readSource = ( ): Result => { switch (parent.operation) { case "procedure_callees": - case "procedure_callers": { + case "procedure_callers": + case "xrefs": { const read = readAddresses(parent); return read.ok ? ok({ operation: parent.operation, result: read.value }) @@ -138,6 +139,7 @@ const observationRows = ( }; case "procedure_callees": case "procedure_callers": + case "xrefs": return { parent_operation: source.operation, total: source.result.length, @@ -302,22 +304,42 @@ export const projectNativeObservationView = ( ], }; if (view.kind === "native-context") { - const { total, ...page } = contextRows(context.value, view); - return ok({ + const selected = contextRows(context.value, view); + const shared = { ...common, kind: "native-context" as const, - ...page, parent_operation: source.value.operation, - coverage: - total === null - ? completeWithinViewCoverage(0, 0) - : pageViewCoverage(view.offset, page.items.rows?.length ?? 0, total), - unknowns: - total === null - ? [ - "Result limitations are not applicable to this primitive observation; null does not report an examined empty list.", - ] - : common.unknowns, + }; + if (selected.facet === "result-limitations") { + const { total, ...page } = selected; + return ok({ + ...shared, + ...page, + coverage: + total === null + ? unavailableViewCoverage() + : pageViewCoverage( + view.offset, + page.items.rows?.length ?? 0, + total, + ), + unknowns: + total === null + ? [ + "Result limitations are not applicable to this primitive observation; null does not report an examined empty list.", + ] + : common.unknowns, + }); + } + const { total, ...page } = selected; + return ok({ + ...shared, + ...page, + coverage: pageViewCoverage( + view.offset, + page.items.rows?.length ?? 0, + total, + ), }); } const selection = view.kind === "summary" ? { offset: 0, limit: 64 } : view; diff --git a/src/domain/analysisView/nativeValueTraceView.ts b/src/domain/analysisView/nativeValueTraceView.ts new file mode 100644 index 0000000000..a26215a5cc --- /dev/null +++ b/src/domain/analysisView/nativeValueTraceView.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { nativeValueTraceSchema } from "../native/nativeValueTrace.js"; +import { err, ok } from "../result.js"; +import type { AnalysisViewParent } from "./analysisViewParent.js"; +import { createAnalysisViewResultReader } from "./analysisViewParent.js"; +import { + assertOwnedView, + type AdmittedView, + type SummaryAnalysisView, +} from "./analysisViewShape.js"; +import { analysisViewInputError } from "./analysisViewErrors.js"; +import { + boundedViewMessages, + completeWithinViewCoverage, +} from "./analysisViewCoverage.js"; + +/** Counts and pagination state for a bounded native value trace page. */ +export const nativeValueTraceSummarySchema = z.strictObject({ + seed: z.string(), + target_sha256: z.string(), + node_count: z.int().nonnegative(), + total_nodes: z.int().nonnegative(), + edge_count: z.int().nonnegative(), + total_edges: z.int().nonnegative(), + procedure_count: z.int().nonnegative(), + unknown_count: z.int().nonnegative(), + decompilations: z.int().nonnegative(), + next_offset: z.int().nonnegative().nullable(), + page_truncated: nativeValueTraceSchema.shape.page_truncated, + traversal: nativeValueTraceSchema.shape.traversal, + truncated: z.boolean(), + limitation_count: z.int().nonnegative(), +}); + +const readTrace = createAnalysisViewResultReader(nativeValueTraceSchema); + +/** Keep the bounded graph page and its completeness state in source Evidence. */ +export const projectNativeValueTraceView = ( + parent: AnalysisViewParent, + view: AdmittedView, +) => { + if (view.kind !== "summary") + return err( + analysisViewInputError([ + { + path: ["view"], + reason: "invalid_value", + message: + "Native value trace Evidence supports the summary view; retrieve parent Evidence for graph nodes and edges.", + }, + ]), + ); + assertOwnedView(view, "summary"); + const parsed = readTrace(parent); + if (!parsed.ok) return parsed; + const trace = parsed.value; + return ok({ + kind: "summary" as const, + view, + parent_evidence_id: parent.evidenceId, + parent_operation: "trace_native_values" as const, + parent_digest: parent.evidenceId.slice(3), + artifact: parent.artifact ?? null, + summary: { + seed: trace.seed, + target_sha256: trace.target_sha256, + node_count: trace.nodes.length, + total_nodes: trace.total_nodes, + edge_count: trace.edges.length, + total_edges: trace.total_edges, + procedure_count: trace.procedures.length, + unknown_count: trace.unknowns.length, + decompilations: trace.decompilations, + next_offset: trace.next_offset, + page_truncated: trace.page_truncated, + traversal: trace.traversal, + truncated: trace.truncated, + limitation_count: trace.limitations.length, + }, + coverage: completeWithinViewCoverage(1, 1), + limitations: boundedViewMessages( + [...parent.limitations, ...trace.limitations], + parent.evidenceId, + "limitations", + ), + unknowns: boundedViewMessages( + trace.unknowns.map( + ({ procedure, address, reason }) => + `${procedure}${address === null ? "" : ` at ${address}`}: ${reason}`, + ), + parent.evidenceId, + "unknowns", + ), + }); +}; diff --git a/src/domain/apple/artifactTargetApplicability.ts b/src/domain/apple/artifactTargetApplicability.ts index 38a9412dfd..b6345d0788 100644 --- a/src/domain/apple/artifactTargetApplicability.ts +++ b/src/domain/apple/artifactTargetApplicability.ts @@ -10,8 +10,9 @@ export const artifactTargetRefusal = ( operation: string, target: BinaryTarget, ): ArtifactTargetRefusal | undefined => { - const bundle = - target.kind === "executable" && target.bundleInfoPlist !== undefined; + // Bundle operations read the bundle directory, whatever its executable + // admitted as; the bundle facts alone establish an .app target. + const bundle = target.bundleInfoPlist !== undefined; const refuse = (reason: string): ArtifactTargetRefusal => ({ code: "target_kind_unsupported", reason, diff --git a/src/domain/artifactComparison.test.ts b/src/domain/artifactComparison.test.ts index 233deaf611..27c3e7cc2e 100644 --- a/src/domain/artifactComparison.test.ts +++ b/src/domain/artifactComparison.test.ts @@ -1,40 +1,25 @@ import { expect, it } from "vitest"; import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js"; -import { compareArtifacts } from "./artifactComparison.js"; +import { + compareArtifacts, + FORMAT_DECLARATION_UNKNOWN_LIMITATION, +} from "./artifactComparison.js"; import { artifactInventoryResultSchema, + type ArtifactInventoryResult, type ArtifactNode, type ArtifactOccurrence, } from "./artifactGraph.js"; -import { artifactGraphDigest, artifactManifestId } from "./artifactIdentity.js"; +import { + artifactGraphDigest, + artifactIdForContent, + artifactManifestId, + occurrenceIdForLocation, +} from "./artifactIdentity.js"; import { createEvidence } from "./evidence.js"; /** Recommit observation variants so comparisons exercise valid Evidence carriers. */ -const packingObservation = ( - unpacked: boolean | undefined, - hashStatus: ArtifactOccurrence["hash_status"] = "verified", - changedContent = false, - occurrenceChanges: Partial = {}, - nodeChanges: Partial = {}, -) => { - const source = changedContent - ? ARTIFACT_COMPARISON_EXAMPLE.right - : ARTIFACT_COMPARISON_EXAMPLE.left; - const inventory = artifactInventoryResultSchema.parse( - source.normalized_result, - ); - inventory.occurrences = inventory.occurrences.map((occurrence) => ({ - ...occurrence, - ...(unpacked === undefined ? {} : { unpacked }), - hash_status: hashStatus, - ...occurrenceChanges, - })); - if (occurrenceChanges.artifact_format !== undefined) - inventory.manifest.root_format = occurrenceChanges.artifact_format; - inventory.nodes = inventory.nodes.map((node) => ({ - ...node, - ...nodeChanges, - })); +const commit = (inventory: ArtifactInventoryResult) => { const graphSha256 = artifactGraphDigest({ nodes: inventory.nodes, occurrences: inventory.occurrences, @@ -52,7 +37,7 @@ const packingObservation = ( sha256: inventory.manifest.root_sha256, format: inventory.manifest.root_format, }, - source.provider, + ARTIFACT_COMPARISON_EXAMPLE.left.provider, { operation: "inventory_artifact", parameters: {}, @@ -63,6 +48,47 @@ const packingObservation = ( ); }; +const observation = ( + facts: Pick & Partial, + changedContent = false, + nodeChanges: Partial = {}, +) => { + const source = changedContent + ? ARTIFACT_COMPARISON_EXAMPLE.right + : ARTIFACT_COMPARISON_EXAMPLE.left; + const inventory = artifactInventoryResultSchema.parse( + source.normalized_result, + ); + inventory.occurrences = inventory.occurrences.map((occurrence) => ({ + ...occurrence, + ...facts, + })); + if (facts.artifact_format !== undefined) + inventory.manifest.root_format = facts.artifact_format; + inventory.nodes = inventory.nodes.map((node) => ({ + ...node, + ...nodeChanges, + })); + return commit(inventory); +}; + +const packingObservation = ( + unpacked: boolean | undefined, + hashStatus: ArtifactOccurrence["hash_status"] = "verified", + changedContent = false, + occurrenceChanges: Partial = {}, + nodeChanges: Partial = {}, +) => + observation( + { + ...(unpacked === undefined ? {} : { unpacked }), + hash_status: hashStatus, + ...occurrenceChanges, + }, + changedContent, + nodeChanges, + ); + it.each([ [{ artifact_kind: "unknown" }, { artifact_kind: "javascript" }, "verified"], [{ artifact_kind: "javascript" }, { artifact_kind: "unknown" }, "verified"], @@ -233,3 +259,350 @@ it("keeps integrity contradictions ahead of packing differences", () => { changes: [{ classification: "contradiction", dimensions: ["integrity"] }], }); }); + +type FormatMetadata = NonNullable; + +const declared = (formatMetadata: FormatMetadata) => + observation({ hash_status: "verified", format_metadata: formatMetadata }); + +/** One metadata record in a container; a content digit gives it a node. */ +const recordObservation = ( + formatMetadata: FormatMetadata | undefined, + record: { + readonly content?: string; + readonly encrypted?: boolean; + readonly executable?: boolean; + readonly limitations?: string[]; + readonly unpacked?: boolean; + } = {}, +) => { + const inventory = artifactInventoryResultSchema.parse( + ARTIFACT_COMPARISON_EXAMPLE.left.normalized_result, + ); + const root = inventory.occurrences[0]; + if (root === undefined) throw new Error("Missing root occurrence"); + const encrypted = record.encrypted ?? false; + const sha256 = record.content?.repeat(64); + const artifactId = sha256 === undefined ? null : artifactIdForContent(sha256); + if (sha256 !== undefined && artifactId !== null) + inventory.nodes.push({ + artifact_id: artifactId, + format: "file", + sha256, + size: 0, + media_type: null, + architecture: null, + content_state: "materialized", + limitations: [], + }); + inventory.occurrences.push({ + occurrence_id: occurrenceIdForLocation({ + rootArtifactId: inventory.manifest.root_artifact_id, + logicalPath: "record", + entryKind: "metadata", + }), + artifact_id: artifactId, + parent_occurrence_id: root.occurrence_id, + logical_path: "record", + entry_kind: "metadata", + artifact_kind: "package-metadata", + artifact_format: "unknown", + declared_size: 0, + compressed_size: null, + executable: record.executable ?? false, + encrypted, + hash_status: + sha256 !== undefined + ? "verified" + : encrypted + ? "unavailable" + : "not-hashed", + source_location: null, + ...(formatMetadata === undefined + ? {} + : { format_metadata: formatMetadata }), + limitations: record.limitations ?? [], + ...(record.unpacked === undefined ? {} : { unpacked: record.unpacked }), + }); + inventory.coverage = { scope: "container-entries", status: "complete" }; + inventory.manifest.node_count = inventory.nodes.length; + inventory.manifest.occurrence_count = inventory.occurrences.length; + return commit(inventory); +}; + +const espImageSegment: FormatMetadata = { + kind: "esp-image-segment", + index: 1, + load_addr: 0x3fc8_8000, + data_len: 4, + header_offset: 24, + classification: "loaded", +}; +const movedSegment: FormatMetadata = { + ...espImageSegment, + load_addr: 0x3fc8_a000, +}; +const runtimeOption: FormatMetadata = { + kind: "pyinstaller-entry", + container: "carchive", + container_name: null, + index: 1, + source_name: "pyi-contents-directory _internal", + type_code: "o", + relative_offset: 0, + directory_record_offset: null, + directory_record_size: null, + compression_flag: 0, + bytecode_header: "not-bytecode", + bytecode_magic: null, + pyz_status: "not-pyz", + pyz_sha256: null, + pyz_python_magic: null, + pyz_python_version: null, + pyz_entry_count: null, + encryption_source: null, +}; + +it("compares an ESP segment declaration without its layout or derived class", () => { + expect( + compareArtifacts( + declared(espImageSegment), + declared({ + ...espImageSegment, + header_offset: 64, + classification: "unknown", + }), + ), + ).toMatchObject({ status: "unchanged", changes: [] }); + expect( + compareArtifacts(declared(espImageSegment), declared(movedSegment)), + ).toMatchObject({ + status: "changed", + changes: [{ classification: "changed", dimensions: ["metadata"] }], + }); +}); + +it("retains a changed declaration when content is unavailable", () => { + expect( + compareArtifacts( + declared(espImageSegment), + observation({ + hash_status: "unavailable", + format_metadata: movedSegment, + }), + ), + ).toMatchObject({ + status: "changed", + changes: [ + { classification: "changed", dimensions: ["metadata", "availability"] }, + ], + }); + // Declared dimensions keep the schema order, ahead of availability. + expect( + compareArtifacts( + declared(espImageSegment), + observation({ + hash_status: "unavailable", + format_metadata: movedSegment, + executable: true, + }), + ).changes, + ).toMatchObject([ + { + classification: "changed", + dimensions: ["executable", "metadata", "availability"], + }, + ]); +}); + +it.each([ + ["one side omits it", undefined], + ["the other side records another kind", runtimeOption], +] as const)("keeps a declaration unknown when %s", (_case, other) => { + const result = compareArtifacts( + declared(espImageSegment), + other === undefined + ? observation({ hash_status: "verified" }) + : declared(other), + ); + expect(result).toMatchObject({ + status: "unknown", + changes: [{ classification: "unknown", dimensions: ["availability"] }], + }); + expect(result.limitations).toEqual([FORMAT_DECLARATION_UNKNOWN_LIMITATION]); +}); + +it("does not compare ESP signature block metadata", () => { + const block: FormatMetadata = { + kind: "esp-image-signature-block", + index: 0, + version: 2, + scheme: "rsa", + sha_version: null, + curve_id: null, + digest_algorithm: "sha256", + image_digest: "a".repeat(64), + image_digest_matches: true, + public_key_sha256: "b".repeat(64), + signature_status: "unverified", + }; + for (const other of [ + declared({ ...block, image_digest_matches: false }), + observation({ hash_status: "verified" }), + ]) + expect(compareArtifacts(declared(block), other)).toMatchObject({ + status: "unchanged", + changes: [], + limitations: [], + }); +}); + +it("does not compare HEX region metadata, which the region's path encodes", () => { + const region: FormatMetadata = { + kind: "hex-record-region", + start_address: 0x0800_0000, + end_address: 0x0800_001f, + length: 32, + record_count: 2, + first_line: 1, + last_line: 2, + }; + // Record and line counts are layout, and equal bytes stay unchanged beside + // an observation without the metadata, such as an extracted region. + for (const other of [ + declared({ ...region, record_count: 4, first_line: 3, last_line: 6 }), + observation({ hash_status: "verified" }), + ]) + expect(compareArtifacts(declared(region), other)).toMatchObject({ + status: "unchanged", + changes: [], + limitations: [], + }); +}); + +it("compares a node-less record of any compared kind by its declaration", () => { + // No reader emits such a record; the rule follows the table, not a format. + expect( + compareArtifacts( + recordObservation(espImageSegment), + recordObservation(espImageSegment), + ), + ).toMatchObject({ status: "unchanged", changes: [] }); + expect( + compareArtifacts( + recordObservation(espImageSegment), + recordObservation(movedSegment), + ).changes, + ).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["metadata"], + }), + ]); +}); + +it("compares node-less records by declaration and keeps content changes", () => { + expect( + compareArtifacts( + recordObservation(runtimeOption), + recordObservation(runtimeOption), + ), + ).toMatchObject({ status: "unchanged", changes: [] }); + expect( + compareArtifacts( + recordObservation(runtimeOption), + recordObservation({ ...runtimeOption, source_name: "pyi-python-flag O" }), + ).changes, + ).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["metadata"], + }), + ]); + // Equal declarations do not hide a change in content that a record names. + expect( + compareArtifacts( + recordObservation(runtimeOption, { content: "a" }), + recordObservation(runtimeOption, { content: "b" }), + ).changes, + ).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["content"], + }), + ]); +}); + +it("compares a node-less record's occurrence fields but not its limitations", () => { + // A PYZ namespace record and the reader's caveat for it. + const namespace: FormatMetadata = { + ...runtimeOption, + container: "pyz", + container_name: "PYZ.pyz", + source_name: "legacy", + type_code: 3, + }; + const caveat = + "Namespace package has no importable bytecode; its declared payload is not materialized."; + const plain = recordObservation(namespace, { limitations: [caveat] }); + // A known encryption change survives a declaration that one side omits. + const result = compareArtifacts( + plain, + recordObservation(undefined, { encrypted: true, limitations: [caveat] }), + ); + expect(result.changes).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["metadata", "availability"], + }), + ]); + expect(result.limitations).toEqual([FORMAT_DECLARATION_UNKNOWN_LIMITATION]); + // A record's `executable` marking is a declared field like `encrypted`. + expect( + compareArtifacts( + plain, + recordObservation(namespace, { executable: true, limitations: [caveat] }), + ).changes, + ).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["executable"], + }), + ]); + // Limitations describe the observation, so a caveat alone is no change. + expect(compareArtifacts(plain, recordObservation(namespace))).toMatchObject({ + status: "unchanged", + changes: [], + }); +}); + +it("compares a node-less record's recorded ASAR storage", () => { + // No reader records storage on a metadata record. A recorded value is a + // declared fact like `encrypted`, and an omitted one stays unknown. + expect( + compareArtifacts( + recordObservation(runtimeOption, { unpacked: false }), + recordObservation(runtimeOption, { unpacked: true }), + ).changes, + ).toEqual([ + expect.objectContaining({ + logical_path: "record", + classification: "changed", + dimensions: ["metadata"], + }), + ]); + expect( + compareArtifacts( + recordObservation(runtimeOption, { unpacked: true }), + recordObservation(runtimeOption), + ), + ).toMatchObject({ + status: "unknown", + changes: [{ classification: "unknown", dimensions: ["availability"] }], + }); +}); diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 214b68cbe6..9736646f80 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -14,12 +14,8 @@ import { type InventorySet, } from "./artifactInventoryEvidence.js"; import { - dfuSeElementDeclaration, - espFlashPartitionDeclaration, - espImageSegmentDeclaration, - fitImageDeclaration, - nodeSeaEntryDeclaration, - pyInstallerEntryDeclaration, + formatMetadataDeclaration, + type FormatMetadataDeclaration, } from "./artifactFormatMetadata.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJson } from "./comparisonSemantics.js"; @@ -100,6 +96,10 @@ export type ArtifactComparisonResult = z.infer< >; type ArtifactChange = z.infer; +/** Comparison limitation when a path's format declarations cannot be compared. */ +export const FORMAT_DECLARATION_UNKNOWN_LIMITATION = + "Format metadata declarations are missing on one side or differ in kind for matching paths; omitted metadata may be a legacy or non-container observation and leaves the complete declaration unavailable."; + const comparisonInventory = (input: unknown, side: "left" | "right") => { try { return parseArtifactInventoryEvidence(input); @@ -186,10 +186,8 @@ export const compareArtifacts = ( "ASAR packing metadata is recorded on only one side for matching paths; omitted metadata may be a legacy observation and does not establish packed or unpacked storage.", ] : []), - ...(comparison.hasUnknownNodeSeaMetadata - ? [ - "Node SEA member metadata is recorded on only one side for matching paths; omitted metadata may be a legacy observation and leaves the complete runtime member declaration unavailable.", - ] + ...(comparison.hasUnknownFormatDeclaration + ? [FORMAT_DECLARATION_UNKNOWN_LIMITATION] : []), ...(comparison.hasAmbiguousDuplicatePaths ? [ @@ -252,7 +250,7 @@ const compareOccurrences = ( readonly changes: ArtifactChange[]; readonly hasAmbiguousDuplicatePaths: boolean; readonly hasUnknownAsarPacking: boolean; - readonly hasUnknownNodeSeaMetadata: boolean; + readonly hasUnknownFormatDeclaration: boolean; } => { const leftNodes = new Map(left.nodes.map((item) => [item.artifact_id, item])); const rightNodes = new Map( @@ -266,7 +264,7 @@ const compareOccurrences = ( const output: ArtifactChange[] = []; let hasAmbiguousDuplicatePaths = false; let hasUnknownAsarPacking = false; - let hasUnknownNodeSeaMetadata = false; + let hasUnknownFormatDeclaration = false; for (const path of paths) { const leftMatches = leftByPath.get(path) ?? []; const rightMatches = rightByPath.get(path) ?? []; @@ -289,7 +287,8 @@ const compareOccurrences = ( const metadata = occurrenceMetadata({ leftOccurrence, rightOccurrence }); if (leftOccurrence !== undefined && rightOccurrence !== undefined) { if (metadata.asarPacking === "unknown") hasUnknownAsarPacking = true; - if (metadata.nodeSea === "unknown") hasUnknownNodeSeaMetadata = true; + if (metadata.formatDeclaration === "unknown") + hasUnknownFormatDeclaration = true; } const change = classifyPath({ metadata, @@ -310,7 +309,7 @@ const compareOccurrences = ( changes: output, hasAmbiguousDuplicatePaths, hasUnknownAsarPacking, - hasUnknownNodeSeaMetadata, + hasUnknownFormatDeclaration, }; }; @@ -407,7 +406,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { if (integrity !== null) return integrity; const presence = classifyPresence(input, base); if (presence !== null) return presence; - const declaration = classifyPyInstallerDeclaration(input, base); + const declaration = classifyDeclarationRecord(input, base); if (declaration.handled) return declaration.change; const availability = classifyAvailability(input, base); if (availability !== null) { @@ -417,7 +416,8 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { if (known.format) dimensions.push("format"); if (known.executable) dimensions.push("executable"); if (known.relations) dimensions.push("relations"); - if (known.metadata) dimensions.push("metadata"); + if (known.metadata || declaredDigestsDiffer(input)) + dimensions.push("metadata"); dimensions.push("availability"); return { ...base, @@ -430,7 +430,7 @@ const classifyPath = (input: ClassifyPathInput): ArtifactChange | null => { const dimensions = changedDimensions(input); const occurrence = occurrenceDifferences(input); if ( - Object.values(input.metadata).includes("unknown") || + declarationUnknown(input.metadata) || occurrence.kindUnknown || occurrence.formatUnknown || differsWithUnknown(input.leftNode?.format, input.rightNode?.format) || @@ -464,24 +464,31 @@ type OccurrencePair = Pick< type MetadataComparison = "unchanged" | "changed" | "unknown"; interface OccurrenceMetadata { + /** ASAR storage is an occurrence field, not format metadata. */ readonly asarPacking: MetadataComparison; - readonly nodeSea: MetadataComparison; - readonly espImageSegment: MetadataComparison; - readonly espFlashPartition: MetadataComparison; - readonly fitImage: MetadataComparison; - readonly fitImageAvailability: MetadataComparison; - readonly dfuSeElement: MetadataComparison; - readonly pyInstaller: MetadataComparison; + /** Every occurrence records `encrypted`, so a pair's comparison is known. */ + readonly encryption: Exclude; + /** Absent when neither occurrence records a compared declaration. */ + readonly formatDeclaration: MetadataComparison | "absent"; + /** Unknown when either side could not read a compared field. */ + readonly formatDeclarationAvailability: Exclude< + MetadataComparison, + "changed" + >; } /** A declaration that differs even when content is unavailable. */ const declarationChanged = (metadata: OccurrenceMetadata): boolean => metadata.asarPacking === "changed" || - metadata.nodeSea === "changed" || - metadata.espImageSegment === "changed" || - metadata.espFlashPartition === "changed" || - metadata.fitImage === "changed" || - metadata.dfuSeElement === "changed"; + metadata.formatDeclaration === "changed"; + +/** A declared fact that differs: a declaration or the encryption. */ +const declaredFactChanged = (metadata: OccurrenceMetadata): boolean => + Object.values(metadata).includes("changed"); + +/** A declared fact that one side omits, records as another kind or cannot read. */ +const declarationUnknown = (metadata: OccurrenceMetadata): boolean => + Object.values(metadata).includes("unknown"); /** Unknown classifications and absent node metadata cannot establish a difference. */ const differsWithUnknown = ( @@ -536,174 +543,134 @@ const occurrenceDifferences = (input: { JSON.stringify(input.leftRelations) !== JSON.stringify(input.rightRelations), metadata: - declarationChanged(input.metadata) || - input.metadata.pyInstaller === "changed" || + declaredFactChanged(input.metadata) || input.leftOccurrence?.entry_kind !== input.rightOccurrence?.entry_kind || - input.leftOccurrence?.encrypted !== input.rightOccurrence?.encrypted || JSON.stringify(input.leftOccurrence?.limitations) !== JSON.stringify(input.rightOccurrence?.limitations), }); -const compareOptionalMetadata = ( - left: T | undefined, - right: T | undefined, - declaration: (value: T) => unknown, -): MetadataComparison => { - if (left === undefined || right === undefined) - return left === right ? "unchanged" : "unknown"; - return canonicalJson(declaration(left)) === canonicalJson(declaration(right)) +const compareAsarPacking = ( + left: boolean | undefined, + right: boolean | undefined, +): MetadataComparison => + left === right ? "unchanged" - : "changed"; + : left === undefined || right === undefined + ? "unknown" + : "changed"; + +/** + * Declarations compare within one kind. A declaration that only one side + * records, including one whose other side records another kind, is unknown. + * A field that either side could not read is unknown, and the other fields + * still compare. + */ +const compareFormatDeclarations = ( + left: FormatMetadataDeclaration | undefined, + right: FormatMetadataDeclaration | undefined, +): Pick< + OccurrenceMetadata, + "formatDeclaration" | "formatDeclarationAvailability" +> => { + if (left === undefined && right === undefined) + return { + formatDeclaration: "absent", + formatDeclarationAvailability: "unchanged", + }; + if (left === undefined || right === undefined || left.kind !== right.kind) + return { + formatDeclaration: "unknown", + formatDeclarationAvailability: "unchanged", + }; + const unreadable = new Set([...left.unreadable, ...right.unreadable]); + const readable = (facts: FormatMetadataDeclaration["facts"]) => + Object.fromEntries( + Object.entries(facts).filter(([field]) => !unreadable.has(field)), + ); + return { + formatDeclaration: + canonicalJson(readable(left.facts)) === + canonicalJson(readable(right.facts)) + ? "unchanged" + : "changed", + formatDeclarationAvailability: Object.keys(left.facts).some((field) => + unreadable.has(field), + ) + ? "unknown" + : "unchanged", + }; }; -/** Interpret each pair's format declarations once, preserving historical omission. */ +/** Interpret each pair's declarations once, preserving historical omission. */ const occurrenceMetadata = (input: OccurrencePair): OccurrenceMetadata => { const left = input.leftOccurrence; const right = input.rightOccurrence; - const fit = compareFitImageDeclarations(input); return { - fitImage: fit.declaration, - fitImageAvailability: fit.availability, - asarPacking: compareOptionalMetadata( - left?.unpacked, - right?.unpacked, - (value) => value, - ), - nodeSea: compareOptionalMetadata( - left?.format_metadata?.kind === "node-sea-entry" - ? left.format_metadata - : undefined, - right?.format_metadata?.kind === "node-sea-entry" - ? right.format_metadata - : undefined, - nodeSeaEntryDeclaration, - ), - espImageSegment: compareOptionalMetadata( - left?.format_metadata?.kind === "esp-image-segment" - ? left.format_metadata - : undefined, - right?.format_metadata?.kind === "esp-image-segment" - ? right.format_metadata - : undefined, - espImageSegmentDeclaration, - ), - dfuSeElement: compareOptionalMetadata( - left?.format_metadata?.kind === "dfuse-element" - ? left.format_metadata - : undefined, - right?.format_metadata?.kind === "dfuse-element" - ? right.format_metadata - : undefined, - dfuSeElementDeclaration, - ), - espFlashPartition: compareOptionalMetadata( - left?.format_metadata?.kind === "esp-flash-partition" - ? left.format_metadata - : undefined, - right?.format_metadata?.kind === "esp-flash-partition" - ? right.format_metadata - : undefined, - espFlashPartitionDeclaration, - ), - pyInstaller: compareOptionalMetadata( - isPyInstallerEntry(left) ? left.format_metadata : undefined, - isPyInstallerEntry(right) ? right.format_metadata : undefined, - pyInstallerEntryDeclaration, + asarPacking: compareAsarPacking(left?.unpacked, right?.unpacked), + encryption: left?.encrypted === right?.encrypted ? "unchanged" : "changed", + ...compareFormatDeclarations( + formatMetadataDeclaration(left?.format_metadata), + formatMetadataDeclaration(right?.format_metadata), ), }; }; -/** Keep known FIT changes even when another declaration is unavailable. */ -const compareFitImageDeclarations = ({ - leftOccurrence: left, - rightOccurrence: right, -}: OccurrencePair): { - readonly declaration: MetadataComparison; - readonly availability: MetadataComparison; -} => { - const lm = - left?.format_metadata?.kind === "fit-image" - ? left.format_metadata - : undefined; - const rm = - right?.format_metadata?.kind === "fit-image" - ? right.format_metadata - : undefined; - if (lm === undefined || rm === undefined) { - const result = compareOptionalMetadata(lm, rm, fitImageDeclaration); - return { - declaration: result, - availability: result === "unknown" ? "unknown" : "unchanged", - }; - } - const ld = fitImageDeclaration(lm); - const rd = fitImageDeclaration(rm); - const keys = Object.keys(ld) as (keyof typeof ld)[]; - let changed = false; - let unavailable = false; - for (const key of keys) { - if ( - lm.unreadable_fields.some((field) => field === key) || - rm.unreadable_fields.some((field) => field === key) - ) { - unavailable = true; - continue; - } - if (ld[key] !== rd[key]) changed = true; - } - return { - declaration: changed ? "changed" : "unchanged", - availability: unavailable ? "unknown" : "unchanged", - }; -}; - -const isPyInstallerEntry = ( - occurrence: ArtifactOccurrence | undefined, -): occurrence is ArtifactOccurrence & { - readonly format_metadata: Extract< - NonNullable, - { readonly kind: "pyinstaller-entry" } - >; -} => occurrence?.format_metadata?.kind === "pyinstaller-entry"; - -const classifyPyInstallerDeclaration = ( +/** + * A node-less metadata record has no content node, so its declared facts are + * all that comparison checks; an unread payload is not compared. Neither are + * its limitations, which readers derive from those facts. + */ +const classifyDeclarationRecord = ( input: ClassifyPathInput, base: ReturnType, ): { readonly handled: boolean; readonly change: ArtifactChange | null } => { - const leftIsDeclaration = input.leftOccurrence?.entry_kind === "metadata"; - const rightIsDeclaration = input.rightOccurrence?.entry_kind === "metadata"; - if (!leftIsDeclaration && !rightIsDeclaration) - return { handled: false, change: null }; - // Metadata is not inherently a PyInstaller declaration. Only apply this - // format-specific comparison when a producer explicitly identified one. + // Metadata is not inherently a declaration. Only apply this comparison + // when a producer explicitly recorded a compared one. A record that names + // content keeps the content comparison. if ( - !isPyInstallerEntry(input.leftOccurrence) && - !isPyInstallerEntry(input.rightOccurrence) + input.leftOccurrence?.entry_kind !== "metadata" || + input.rightOccurrence?.entry_kind !== "metadata" || + input.leftOccurrence.artifact_id !== null || + input.rightOccurrence.artifact_id !== null || + input.metadata.formatDeclaration === "absent" ) return { handled: false, change: null }; - if (!leftIsDeclaration || !rightIsDeclaration) - return { handled: false, change: null }; - if ( - !isPyInstallerEntry(input.leftOccurrence) || - !isPyInstallerEntry(input.rightOccurrence) - ) + const dimensions: ArtifactChange["dimensions"] = []; + if (input.leftOccurrence.executable !== input.rightOccurrence.executable) + dimensions.push("executable"); + if (declaredFactChanged(input.metadata)) dimensions.push("metadata"); + const unknown = declarationUnknown(input.metadata); + if (dimensions.length === 0) return { handled: true, - change: { - ...base, - classification: "unknown", - dimensions: ["availability"], - }, + change: unknown + ? { ...base, classification: "unknown", dimensions: ["availability"] } + : null, }; - const changed = input.metadata.pyInstaller === "changed"; + if (unknown) dimensions.push("availability"); return { handled: true, - change: changed - ? { ...base, classification: "changed", dimensions: ["metadata"] } - : null, + change: { ...base, classification: "changed", dimensions }, }; }; +/** + * Without observed content on both sides, the containers' declared digests + * are the remaining content evidence. Claims of one algorithm that differ + * are a metadata difference, but they are unverified, so they do not make + * the path changed; equal claims add nothing. Observed bytes replace both. + */ +const declaredDigestsDiffer = (input: OccurrencePair): boolean => { + const left = input.leftOccurrence?.declared_integrity; + const right = input.rightOccurrence?.declared_integrity; + return ( + left !== undefined && + right !== undefined && + left.algorithm === right.algorithm && + left.digest !== right.digest + ); +}; + const changedDimensions = (input: { readonly metadata: OccurrenceMetadata; readonly leftNode: ArtifactNode | undefined; diff --git a/src/domain/artifactFormatMetadata.ts b/src/domain/artifactFormatMetadata.ts index d6f8e95479..ab07a138ab 100644 --- a/src/domain/artifactFormatMetadata.ts +++ b/src/domain/artifactFormatMetadata.ts @@ -63,6 +63,24 @@ export const unityFsEntryMetadataSchema = z.strictObject({ flags: uint32, }); +/** Parsed source fields owned by the wxapkg adapter, independent of member bytes. */ +export const wxapkgMetadataSchema = z.strictObject({ + kind: z.literal("wxapkg"), + info: uint32.nullable(), + index_length: uint32.nullable(), + data_length: uint32.nullable(), + file_count: uint32.nullable(), + directory_status: z.enum(["available", "encrypted"]), +}); +export type WxapkgMetadata = z.infer; + +export const wxapkgEntryMetadataSchema = z.strictObject({ + kind: z.literal("wxapkg-entry"), + index: uint32, + source_path: z.string(), + declared_offset: offset, +}); + /** Source cookie fields and derived directory counts without Python execution. */ export const pyInstallerMetadataSchema = z.strictObject({ kind: z.literal("pyinstaller"), @@ -235,14 +253,6 @@ export const dfuSeElementMetadataSchema = z.strictObject({ length: offset, }); -/** The device memory an element targets, which its path and bytes do not show. */ -export const dfuSeElementDeclaration = ( - metadata: z.infer, -) => ({ - alternate_setting: metadata.alternate_setting, - target_name: metadata.target_name, -}); - /** * Intel HEX or Motorola S-record facts derived from every record. Start * addresses are declared values, not proven entry points. @@ -359,23 +369,6 @@ export const fitImageMetadataSchema = z.strictObject({ signature_algorithms: z.array(z.string()), }); export type FitImageMetadata = z.infer; -/** Payload interpretation and sharing declarations, independent of FIT placement. */ -export const fitImageDeclaration = (metadata: FitImageMetadata) => ({ - type: metadata.type, - arch: metadata.arch, - os: metadata.os, - compression: metadata.compression, - load: metadata.load, - entry: metadata.entry, - image_data: metadata.image_data, - cipher_present: metadata.cipher_present, - cipher_algo: metadata.cipher_algo, -}); - -/** Runtime member identity, independent of blob layout and directory order. */ -export const nodeSeaEntryDeclaration = ( - metadata: z.infer, -) => ({ role: metadata.role, source_path: metadata.source_path }); export const uint8 = z.number().int().min(0).max(0xff); const uint16 = z.number().int().min(0).max(0xffff); @@ -509,14 +502,6 @@ export type EspImageSegmentMetadata = z.infer< typeof espImageSegmentMetadataSchema >; -/** - * What a segment header declares: where its bytes load and how many there - * are. Header offsets are layout, and the classification is derived. - */ -export const espImageSegmentDeclaration = ( - metadata: EspImageSegmentMetadata, -) => ({ load_addr: metadata.load_addr, data_len: metadata.data_len }); - /** One Secure Boot v2 block with its 0xE7 magic and a matching CRC32. */ export const espImageSignatureBlockMetadataSchema = z.strictObject({ kind: z.literal("esp-image-signature-block"), @@ -576,27 +561,6 @@ export const espFlashPartitionMetadataSchema = z.strictObject({ written_end: uint32.nullable(), written_bytes: uint32.nullable(), }); -export type EspFlashPartitionMetadata = z.infer< - typeof espFlashPartitionMetadataSchema ->; - -/** - * What a partition entry declares; its index is layout, and the written - * fields are observed from its bytes, which content comparison covers. - */ -export const espFlashPartitionDeclaration = ( - metadata: EspFlashPartitionMetadata, -) => ({ - label: metadata.label, - label_hex: metadata.label_hex, - type: metadata.type, - subtype: metadata.subtype, - flash_address: metadata.flash_address, - size: metadata.size, - encrypted: metadata.encrypted, - readonly: metadata.readonly, - unknown_flags: metadata.unknown_flags, -}); /** ESP image facts for a region of a dump; offsets are relative to the region. */ export const espFlashImageFactsSchema = z.strictObject({ @@ -721,16 +685,6 @@ export const espFlashImageMetadataSchema = z.strictObject({ }); export type EspFlashImageMetadata = z.infer; -/** Declaration identity that is stable across archive layout changes. */ -export const pyInstallerEntryDeclaration = ( - metadata: PyInstallerEntryMetadata, -) => ({ - container: metadata.container, - container_name: metadata.container_name, - source_name: metadata.source_name, - type_code: metadata.type_code, -}); - /** * U-Boot legacy uImage header facts. Declared addresses are facts from the * header, not proven entry points; CRCs are verified before inventory. @@ -829,6 +783,8 @@ export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [ godotPckEntryMetadataSchema, unityFsMetadataSchema, unityFsEntryMetadataSchema, + wxapkgMetadataSchema, + wxapkgEntryMetadataSchema, pyInstallerMetadataSchema, pyInstallerEntryMetadataSchema, dotnetBundleEntryMetadataSchema, @@ -855,3 +811,224 @@ export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [ export type ArtifactFormatMetadata = z.infer< typeof artifactFormatMetadataSchema >; + +type FormatMetadataByKind = { + readonly [Metadata in ArtifactFormatMetadata as Metadata["kind"]]: Metadata; +}; +type FormatMetadataKind = keyof FormatMetadataByKind; + +/** + * What each kind declares beyond its own bytes, which artifact comparison + * compares, or why comparison uses none of its facts. Every kind needs an + * entry, so a new kind does not compile without this decision; a new field + * of an existing kind still needs a review of its projection. For a node-less + * metadata record, the projection is all of its format metadata that + * comparison checks. + */ +const declarationsByKind: { + readonly [Kind in FormatMetadataKind]: + | { + readonly declaration: ( + metadata: FormatMetadataByKind[Kind], + ) => FormatMetadataDeclaration["facts"]; + /** Projected fields the producer could not interpret. */ + readonly unreadable?: ( + metadata: FormatMetadataByKind[Kind], + ) => FormatMetadataDeclaration["unreadable"]; + } + | { readonly notCompared: string }; +} = { + "godot-pck": { + notCompared: + "Its fields come from the pack's own bytes, which content comparison covers.", + }, + "godot-pck-entry": { + notCompared: + "index and declared_offset are layout; flags and md5_status repeat occurrence facts; source_path spells Godot's lookup path in a version-dependent way. docs/godot-pck.md gives the evidence.", + }, + unityfs: { + notCompared: + "Its fields come from the bundle's own bytes, which content comparison covers.", + }, + /** What the directory declares for a member: its source path and raw node flags. Index and offset are layout. */ + "unityfs-entry": { + declaration: (metadata) => ({ + source_path: metadata.source_path, + flags: metadata.flags, + }), + }, + wxapkg: { + notCompared: + "Its fields come from the package's own bytes, which content comparison covers.", + }, + "wxapkg-entry": { + notCompared: + "index is layout. source_path keeps the declared name, including a producer root prefix the occurrence path strips.", + }, + pyinstaller: { + notCompared: + "Its fields come from the archive's own bytes, which content comparison covers.", + }, + /** Declaration identity that is stable across archive layout changes. */ + "pyinstaller-entry": { + declaration: (metadata) => ({ + container: metadata.container, + container_name: metadata.container_name, + source_name: metadata.source_name, + type_code: metadata.type_code, + }), + }, + "dotnet-bundle-entry": { + notCompared: + "Its fields come from the host's bundle manifest, whose bytes the host's content comparison covers.", + }, + "bun-standalone": { + notCompared: + "Its fields come from the carrier's own bytes, which content comparison covers.", + }, + "bun-standalone-entry": { + notCompared: + "Its fields come from the carrier's module graph, whose bytes the carrier's content comparison covers.", + }, + "node-sea": { + notCompared: + "Its fields come from the carrier's own bytes, which content comparison covers.", + }, + /** Runtime member identity, independent of blob layout and directory order. */ + "node-sea-entry": { + declaration: (metadata) => ({ + role: metadata.role, + source_path: metadata.source_path, + }), + }, + "hex-records": { + notCompared: + "Its fields come from the file's own records, which content comparison covers.", + }, + "hex-record-region": { + notCompared: + "start_address and end_address restate the region's path, and length follows from them; record and line counts are layout.", + }, + uimage: { + notCompared: + "Its header facts come from the image's own bytes, which content comparison covers.", + }, + "uimage-entry": { + notCompared: + "role and index restate the entry's path; its stored bytes are compared by content.", + }, + "esp-image": { + notCompared: + "Its fields come from the image's own bytes, which content comparison covers.", + }, + /** + * What a segment header declares: where its bytes load and how many there + * are. Header offsets are layout, and the classification is derived. + */ + "esp-image-segment": { + declaration: (metadata) => ({ + load_addr: metadata.load_addr, + data_len: metadata.data_len, + }), + }, + "esp-image-signature-block": { + notCompared: + "Its fields come from the block's own bytes, which content comparison covers, and image_digest_matches follows the image's content.", + }, + uf2: { + notCompared: + "Its counts come from the file's own blocks, which content comparison covers, and family_name_registry names REA's label registry.", + }, + "uf2-region": { + notCompared: + "Its familyID and addresses are in its path, family_name is REA's label for the familyID, and its block counts and positions are layout; content comparison covers its bytes.", + }, + dfuse: { + notCompared: + "Its fields come from the file's own prefix, target headers and suffix, which content comparison covers.", + }, + /** The device memory an element targets, which its path and bytes do not show. */ + "dfuse-element": { + declaration: (metadata) => ({ + alternate_setting: metadata.alternate_setting, + target_name: metadata.target_name, + }), + }, + "esp-flash-image": { + notCompared: + "Its fields come from the dump's bytes and from the caller's flash_offset and partition_table_offset, which are target identity; each partition's range, compared by content, reflects them.", + }, + /** + * What a partition entry declares; its index is layout, and the written + * fields are observed from its bytes, which content comparison covers. + */ + "esp-flash-partition": { + declaration: (metadata) => ({ + label: metadata.label, + label_hex: metadata.label_hex, + type: metadata.type, + subtype: metadata.subtype, + flash_address: metadata.flash_address, + size: metadata.size, + encrypted: metadata.encrypted, + readonly: metadata.readonly, + unknown_flags: metadata.unknown_flags, + }), + }, + fit: { + notCompared: + "Its fields come from the FIT's own bytes, which content comparison covers.", + }, + /** + * Payload interpretation and sharing declarations, independent of FIT + * placement: the image's name, index and data position are layout. + */ + "fit-image": { + declaration: (metadata) => ({ + type: metadata.type, + arch: metadata.arch, + os: metadata.os, + compression: metadata.compression, + load: metadata.load, + entry: metadata.entry, + image_data: metadata.image_data, + cipher_present: metadata.cipher_present, + cipher_algo: metadata.cipher_algo, + }), + unreadable: (metadata) => metadata.unreadable_fields, + }, +}; + +/** Kinds whose declarations artifact comparison compares, in table order. */ +export const COMPARED_FORMAT_METADATA_KINDS = Object.entries( + declarationsByKind, +).flatMap(([kind, entry]) => ("declaration" in entry ? [kind] : [])); + +/** A declaration that artifact comparison compares within its kind. */ +export interface FormatMetadataDeclaration { + readonly kind: FormatMetadataKind; + readonly facts: Readonly>; + /** Facts whose value is unknown, which compare as neither equal nor changed. */ + readonly unreadable: readonly string[]; +} + +// Generic in the kind, so each projection receives its own metadata type. +const declarationOf = ( + kind: Kind, + metadata: FormatMetadataByKind[Kind], +): FormatMetadataDeclaration | undefined => { + const entry = declarationsByKind[kind]; + return "declaration" in entry + ? { + kind, + facts: entry.declaration(metadata), + unreadable: entry.unreadable?.(metadata) ?? [], + } + : undefined; +}; + +/** The compared declaration, or undefined when comparison uses none. */ +export const formatMetadataDeclaration = ( + metadata: ArtifactFormatMetadata | undefined, +): FormatMetadataDeclaration | undefined => + metadata === undefined ? undefined : declarationOf(metadata.kind, metadata); diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 9528ba6885..675e7c828e 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -67,6 +67,7 @@ export const ARTIFACT_FORMAT_IDS = [ "ines", "godot-pck", "unityfs", + "wxapkg", "uimage", "javascript-bundle", "source-map", @@ -113,6 +114,7 @@ const artifactContentFormatSchema = artifactFormatSchema.extract([ "zip", "godot-pck", "unityfs", + "wxapkg", "uimage", "intel-hex", "srec", diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index 743a92c9a2..22e6f3e848 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -293,6 +293,7 @@ export type BinaryTarget = | "ines" | "godot-pck" | "unityfs" + | "wxapkg" | "uimage" | "dmg" | "pkg" diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index bafa37e644..b020db89db 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -4,6 +4,7 @@ import { utcDateTimeSchema } from "./offsetDateTime.js"; import { browserCompletenessSchema } from "./browserCompleteness.js"; import { cdpDomSnapshotSchema, + cdpResourceColumnSchema, cdpScriptSourceSchema, cdpScriptMetadataSchema, } from "./cdpObservationSchemas.js"; @@ -125,7 +126,7 @@ const browserNetworkRequestSchema = z.object({ type: z.string(), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }), body_shapes: z.object({ status: z.enum(["not_approved", "included", "partial", "unavailable"]), @@ -149,7 +150,7 @@ const browserConsoleEventSchema = z.object({ ), url: z.string().nullable(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), text_capture: z.object({ status: z.enum(["not_approved", "included", "unavailable"]), values: z.array( diff --git a/src/domain/browserScenarioCapture.test.ts b/src/domain/browserScenarioCapture.test.ts index fa1dcb743b..293b7003cc 100644 --- a/src/domain/browserScenarioCapture.test.ts +++ b/src/domain/browserScenarioCapture.test.ts @@ -70,6 +70,33 @@ it("parses each network outcome without contradictory status or failure data", ( ).toBe(false); }); +it("accepts three-digit HTTP response status codes including statuses above 599", () => { + expect( + browserScenarioEventSchema.safeParse({ + ...networkEvent, + kind: "response", + status: 999, + failure: null, + }).success, + ).toBe(true); + expect( + browserScenarioEventSchema.safeParse({ + ...networkEvent, + kind: "response", + status: 1000, + failure: null, + }).success, + ).toBe(false); + expect( + browserScenarioEventSchema.safeParse({ + ...networkEvent, + kind: "response", + status: 99, + failure: null, + }).success, + ).toBe(false); +}); + it("keeps WebSocket payload representation tied to its payload type", () => { const frame = { sequence: 1, diff --git a/src/domain/browserScenarioCaptureValues.ts b/src/domain/browserScenarioCaptureValues.ts index 04021df1ca..436c69e53d 100644 --- a/src/domain/browserScenarioCaptureValues.ts +++ b/src/domain/browserScenarioCaptureValues.ts @@ -210,7 +210,7 @@ export const browserScenarioEventSchema = z z.strictObject({ ...networkEventShape, kind: z.literal("response"), - status: z.int().min(100).max(599), + status: z.int().min(100).max(999), failure: z.null(), }), z.strictObject({ diff --git a/src/domain/browserScenarioValues.ts b/src/domain/browserScenarioValues.ts index bf42e54f6c..e6edf5fbfc 100644 --- a/src/domain/browserScenarioValues.ts +++ b/src/domain/browserScenarioValues.ts @@ -437,7 +437,7 @@ export const browserScenarioActionSchema = z.discriminatedUnion("action", [ z .int() .min(100) - .max(599) + .max(999) .refine((status) => status < 300 || status > 399, { message: "Request steps follow no redirects, so a 3xx status cannot complete one", diff --git a/src/domain/cdpObservationSchemas.ts b/src/domain/cdpObservationSchemas.ts index 8af603df05..6e705d1192 100644 --- a/src/domain/cdpObservationSchemas.ts +++ b/src/domain/cdpObservationSchemas.ts @@ -1,6 +1,15 @@ import { z } from "zod"; import { webTextArtifactSchema } from "./webContentArtifact.js"; +/** + * Zero-based UTF-16 column in a resource, as CDP reports it. CDP declares + * columns as plain integers. Chrome reports a negative start column for some + * inline scripts, and positions on a script's first line include that start + * column, so a negative column is kept as reported. It does not locate a + * column in the resource. + */ +export const cdpResourceColumnSchema = z.int(); + /** Structural DOM facts shared by passive web and Electron observations. */ export const cdpDomNodeSchema = z.object({ index: z.number().int().min(0), diff --git a/src/domain/desktopApplication.ts b/src/domain/desktopApplication.ts index 77cf6c9a95..2d7effd10b 100644 --- a/src/domain/desktopApplication.ts +++ b/src/domain/desktopApplication.ts @@ -93,6 +93,11 @@ const desktopFactSchema = z.discriminatedUnion("kind", [ offset: z.null(), value: z.string(), }), + z.strictObject({ + kind: z.literal("unity-il2cpp-backup-convention"), + offset: z.null(), + value: z.strictObject({ backup_directory: z.string() }), + }), z.strictObject({ kind: z.literal("electron-asar-convention"), offset: z.null(), @@ -162,6 +167,7 @@ export const desktopProbeSchema = z.strictObject({ probe_id: prefixedDigestSchema("dsp"), operation: z.enum([ "inspect_managed_artifact", + "inspect_managed_members", "inspect_il2cpp_metadata", "open_binary", "analyze_javascript_application", diff --git a/src/domain/desktopApplicationProjection.test.ts b/src/domain/desktopApplicationProjection.test.ts index 0ca7ddd1cb..d3adc89173 100644 --- a/src/domain/desktopApplicationProjection.test.ts +++ b/src/domain/desktopApplicationProjection.test.ts @@ -62,6 +62,11 @@ const facts: DesktopFact[] = [ value: { container: "FORM", first_chunk: "GEN8" }, }, { kind: "il2cpp-library-convention", offset: null, value: "p" }, + { + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: "B" }, + }, { kind: "electron-asar-convention", offset: null, value: "p" }, { kind: "unreal-container-convention", offset: null, value: "p" }, ]; diff --git a/src/domain/desktopApplicationProjection.ts b/src/domain/desktopApplicationProjection.ts index b7e47c3a0c..2cd31d218c 100644 --- a/src/domain/desktopApplicationProjection.ts +++ b/src/domain/desktopApplicationProjection.ts @@ -1,5 +1,6 @@ import { IL2CPP_METADATA_VERSIONS } from "./il2cpp/il2cppMetadata.js"; import { digestCanonicalValue } from "./canonicalDigest.js"; +import { desktopPathConventions } from "./nativeConvention.js"; import type { DesktopApplication, DesktopFile } from "./desktopApplication.js"; /** The most next probes this projection derives from any one fact. */ @@ -53,13 +54,42 @@ export const projectDesktopApplication = ( managed, "PE optional headers declare nonempty CLI directories; CLR execution and metadata validity remain unverified.", ); + const mono = managed.filter((file) => has(file, "unity-assembly-reference")); + const monoDirectories = new Set( + mono.flatMap((file) => + file.facts.flatMap((fact) => + fact.kind === "unity-assembly-reference" + ? [fact.value.managed_directory] + : [], + ), + ), + ); + for (const file of managed) { + const conventions = desktopPathConventions(file.path); + if ( + conventions.unityPredefinedAssembly && + conventions.unityManagedDirectory !== null && + monoDirectories.has(conventions.unityManagedDirectory) + ) + probe( + "inspect_managed_members", + file, + 'Inventory types, members and user strings of this Unity predefined script assembly; its references and contents are unverified. Start with detail "summary" and page rows through inspect_analysis_view.', + ); + } + for (const file of managed) + if (has(file, "unity-il2cpp-backup-convention")) + probe( + "inspect_managed_members", + file, + 'Inventory types, members and user strings of this predefined script assembly in a Unity IL2CPP backup folder; its correspondence to the shipped native code is unknown. Start with detail "summary" and page rows through inspect_analysis_view.', + ); for (const file of managed) probe( "inspect_managed_artifact", file, "Inspect the declared managed metadata and references.", ); - const mono = managed.filter((file) => has(file, "unity-assembly-reference")); candidate( "unity-mono", mono, diff --git a/src/domain/evidenceBundle.test.ts b/src/domain/evidenceBundle.test.ts index dab9ce9295..ddd2d71f69 100644 --- a/src/domain/evidenceBundle.test.ts +++ b/src/domain/evidenceBundle.test.ts @@ -127,6 +127,39 @@ const makeUnknown = ( }; }; +it("retains only linked cross-target source closure in a target snapshot", () => { + const leaf = createEvidence(undefined, provider, { + operation: "source_leaf", + parameters: {}, + result: "leaf", + }); + const otherTarget = { ...ANALYSIS_SNAPSHOT_TARGET, sha256: foreignDigest }; + const source = createEvidence(otherTarget, provider, { + operation: "source", + parameters: {}, + result: "source", + evidenceLinks: [leaf.evidence_id], + }); + const trace = createEvidence(ANALYSIS_SNAPSHOT_TARGET, provider, { + operation: "trace", + parameters: {}, + result: "trace", + evidenceLinks: [source.evidence_id, "ev_" + "c".repeat(64)], + }); + const unrelated = createEvidence(otherTarget, provider, { + operation: "unrelated", + parameters: {}, + result: "unrelated", + }); + const selected = evidenceBundleForTarget( + createEvidenceBundle([trace, source, leaf, unrelated]), + targetDigest, + ); + expect(selected).toEqual(createEvidenceBundle([trace, source, leaf])); + expect(evidenceBundleForTarget(selected, targetDigest)).toEqual(selected); + expect(parseEvidenceBundle(selected)).toEqual(selected); +}); + describe("evidenceBundleForTarget", () => { it("retains mutation evidence from other targets for complete revision histories", () => { const first = makeUnknown( diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index f874ff3081..0700dab9ca 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -157,7 +157,7 @@ export const createEvidenceBundle = ( }; }; -/** Retain target records and eligible complete unknown histories with their mutation evidence. */ +/** Retain target records, their linked sources, and eligible complete unknown histories. */ export const evidenceBundleForTarget = ( bundle: EvidenceBundle, sha256: string, @@ -218,12 +218,30 @@ export const evidenceBundleForTarget = ( const mutationIds = new Set( unknowns.flatMap(({ mutation_evidence_ids: ids }) => ids), ); + const selectedIds = new Set( + bundle.records + .filter( + (record) => + record.subject?.digest.sha256 === sha256 || + mutationIds.has(record.evidence_id), + ) + .map((record) => record.evidence_id), + ); + // Source Evidence may have no subject or describe a different artifact. + // Follow only explicit links from selected records, retaining missing links + // as missing rather than inventing or importing unrelated observations. + const pendingEvidence = [...selectedIds]; + for (let index = 0; index < pendingEvidence.length; index += 1) { + const record = recordsById.get(pendingEvidence[index] ?? ""); + for (const id of record?.evidence_links ?? []) { + if (!selectedIds.has(id) && recordsById.has(id)) { + selectedIds.add(id); + pendingEvidence.push(id); + } + } + } return createEvidenceBundle( - bundle.records.filter( - (record) => - record.subject?.digest.sha256 === sha256 || - mutationIds.has(record.evidence_id), - ), + bundle.records.filter((record) => selectedIds.has(record.evidence_id)), unknowns, ); }; diff --git a/src/domain/evidenceInputContract.ts b/src/domain/evidenceInputContract.ts index bbd6fc67d1..19440f23ce 100644 --- a/src/domain/evidenceInputContract.ts +++ b/src/domain/evidenceInputContract.ts @@ -11,6 +11,7 @@ type EvidenceInputDeclaration = // Zod registries carry owner metadata through describe/meta clones. This is a // schema declaration registry, never a store of caller Evidence or references. const declarations = z.registry(); +const referenceAlternatives = z.registry<{ readonly retained: true }>(); const borrowedDeclarations = new WeakMap(); /** Inspect authored declarations, including described clones, for catalog checks. */ @@ -25,6 +26,14 @@ export const declareEvidenceInput = ( return schema; }; +/** Mark a source union that accepts both inline Evidence and a retained reference. */ +export const declareEvidenceReferenceAlternative = ( + schema: Schema, +): Schema => { + referenceAlternatives.add(schema, { retained: true }); + return schema; +}; + /** Keep an SDK-borrowed selection associated with its owning declaration. */ export const declareBorrowedEvidenceInput = ( schema: Schema, @@ -94,6 +103,8 @@ type Visitor = ( export interface EvidenceInputContract { readonly frameSchema: z.ZodType; readonly hasEvidence: boolean; + /** A declared field can replace inline Evidence with a retained reference. */ + readonly hasEvidenceReferenceAlternative: boolean; resolve( input: unknown, visit: Visitor, @@ -102,9 +113,13 @@ export interface EvidenceInputContract { } const contracts = new WeakMap(); -const identity = (schema: z.ZodType): EvidenceInputContract => ({ +const identity = ( + schema: z.ZodType, + hasEvidenceReferenceAlternative = false, +): EvidenceInputContract => ({ frameSchema: schema, hasEvidence: false, + hasEvidenceReferenceAlternative, resolve: (input) => ok(input), }); @@ -129,11 +144,14 @@ export const getEvidenceInputContract = ( }; const compile = (schema: z.ZodType): EvidenceInputContract => { + if (referenceAlternatives.get(schema) !== undefined) + return identity(schema, true); const declared = declarations.get(schema); if (declared?.kind === "evidence") return { frameSchema: z.unknown().nonoptional(), hasEvidence: true, + hasEvidenceReferenceAlternative: true, resolve: (input, visit, path = []) => visit(input, path), }; if (declared?.kind === "borrowed") { @@ -156,9 +174,11 @@ const compile = (schema: z.ZodType): EvidenceInputContract => { schema instanceof z.ZodReadonly ) { const child = getEvidenceInputContract(schema.unwrap() as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: replaceWrappedSchema(schema, child.frameSchema), resolve: (input, visit, path = []) => input === undefined || @@ -185,9 +205,14 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { [key, getEvidenceInputContract(child as z.ZodType)] as const, ); const selected = children.filter(([, child]) => child.hasEvidence); - if (selected.length === 0) return identity(schema); + const hasEvidenceReferenceAlternative = children.some( + ([, child]) => child.hasEvidenceReferenceAlternative, + ); + if (selected.length === 0) + return identity(schema, hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, @@ -215,9 +240,11 @@ const objectContract = (schema: z.ZodObject): EvidenceInputContract => { const arrayContract = (schema: z.ZodArray): EvidenceInputContract => { const child = getEvidenceInputContract(schema.element as z.ZodType); - if (!child.hasEvidence) return identity(schema); + if (!child.hasEvidence) + return identity(schema, child.hasEvidenceReferenceAlternative); return { hasEvidence: true, + hasEvidenceReferenceAlternative: child.hasEvidenceReferenceAlternative, frameSchema: schema.clone( { ...schema.def, element: child.frameSchema }, { parent: true }, @@ -242,7 +269,11 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { const options = schema.options.map((option) => getEvidenceInputContract(option as z.ZodType), ); - if (!options.some((option) => option.hasEvidence)) return identity(schema); + const hasEvidenceReferenceAlternative = options.some( + (option) => option.hasEvidenceReferenceAlternative, + ); + if (!options.some((option) => option.hasEvidence)) + return identity(schema, hasEvidenceReferenceAlternative); // Capture the successful branch while parsing; choosing it again later would // repeat defaults, overwrites and refinements. Keys are only parser results. const branches = new WeakMap(); @@ -257,6 +288,7 @@ const unionContract = (schema: z.ZodUnion): EvidenceInputContract => { ); return { hasEvidence: true, + hasEvidenceReferenceAlternative, frameSchema: frame.clone({ ...frame.def, ...(schema.def.error === undefined ? {} : { error: schema.def.error }), diff --git a/src/domain/evidenceRecordSummary.ts b/src/domain/evidenceRecordSummary.ts index 01c4ed2278..373d028fdd 100644 --- a/src/domain/evidenceRecordSummary.ts +++ b/src/domain/evidenceRecordSummary.ts @@ -53,6 +53,12 @@ export const evidenceBundleSummarySchema = z.strictObject({ total_retained_records: countSchema, matching_records: countSchema, unknown_revisions: countSchema, + direct_links: countSchema.describe( + "Distinct evidence_links of each matching record; detail links names them.", + ), + missing_direct_links: countSchema.describe( + "Those direct links whose records this session or bundle does not retain. Zero establishes no transitive closure or analysis coverage.", + ), records: z.array( z.strictObject({ evidence_id: evidenceSchema.shape.evidence_id, @@ -149,13 +155,24 @@ function* matchingEvidenceRecords( } } +/** A record's distinct direct links; the Evidence schema does not deduplicate them. */ +const directLinksOf = ({ evidence_links: links }: Evidence): string[] => + uniqueSorted(links); + /** Discover retained Evidence without reading or copying its complete payloads. */ export const summarizeEvidenceBundle = ( bundle: EvidenceBundle, filters: EvidenceRecordFilters = {}, ): EvidenceBundleSummary => { const records: EvidenceBundleSummary["records"] = []; - for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) + const retained = new Set(bundle.records.map(({ evidence_id: id }) => id)); + let directLinks = 0; + let missingDirectLinks = 0; + for (const { evidence, native } of matchingEvidenceRecords(bundle, filters)) { + for (const link of directLinksOf(evidence)) { + directLinks += 1; + if (!retained.has(link)) missingDirectLinks += 1; + } records.push({ evidence_id: evidence.evidence_id, subject: evidence.subject, @@ -168,12 +185,15 @@ export const summarizeEvidenceBundle = ( limitation_count: evidence.limitations.length, native_dossier: native, }); + } return { kind: "evidence-bundle-summary", filters: selectedFilters(filters), total_retained_records: bundle.records.length, matching_records: records.length, unknown_revisions: bundle.unknowns.length, + direct_links: directLinks, + missing_direct_links: missingDirectLinks, records, }; }; @@ -243,3 +263,142 @@ export const selectEvidenceRecords = ( limitations: [...EVIDENCE_RECORD_SELECTION_LIMITATIONS], }; }; + +/** How far link inspection reads from the selected records. */ +export const evidenceLinkTraversalSchema = z.enum([ + "direct", + "retained-closure", +]); +/** Which inspected links a page returns; counts always cover all of them. */ +export const evidenceLinkStatusFilterSchema = z.enum([ + "missing", + "retained", + "all", +]); + +/** Default page size for link rows, which carry two identifiers each. */ +export const EVIDENCE_LINK_INSPECTION_LIMIT = 64; + +/** Bounds what link membership establishes, in every inspection. */ +export const EVIDENCE_LINK_INSPECTION_LIMITATIONS = [ + "Link status is membership in this session or bundle: missing means absent here, not nonexistent, and neither status describes analysis coverage or a record's meaning.", +] as const; + +/** Direct links, or the retained closure, of selected records without their payloads. */ +export const evidenceLinkInspectionSchema = z.strictObject({ + kind: z.literal("evidence-link-inspection"), + filters: z.record(z.string(), z.string()), + traversal: evidenceLinkTraversalSchema, + link_status: evidenceLinkStatusFilterSchema, + total_retained_records: countSchema, + matching_records: countSchema, + examined_records: countSchema.describe( + "Retained records whose evidence_links were read: the matching records, plus those reached through retained links when traversal is retained-closure.", + ), + links: z + .strictObject({ + total: countSchema, + retained: countSchema, + missing: countSchema, + }) + .describe("Every link read from the examined records, before link_status."), + retained_closure: z + .enum(["complete", "incomplete"]) + .nullable() + .describe( + "With retained-closure traversal, incomplete means a link could not be followed, so what lies beyond it remains unknown. Null for direct traversal or an empty selection, which make no closure claim.", + ), + offset: countSchema, + limit: z.int().positive(), + matching_links: countSchema, + next_offset: countSchema.nullable(), + rows: z.array( + z.strictObject({ + evidence_id: prefixedDigestSchema("ev"), + linked_evidence_id: prefixedDigestSchema("ev"), + status: z.enum(["retained", "missing"]), + }), + ), + limitations: z.array(z.string()), +}); +export type EvidenceLinkInspection = z.infer< + typeof evidenceLinkInspectionSchema +>; + +/** Read link membership without copying or parsing any record payload. */ +export const inspectEvidenceLinks = ( + bundle: EvidenceBundle, + filters: EvidenceRecordFilters = {}, + options: { + readonly traversal?: + | z.infer + | undefined; + readonly linkStatus?: + | z.infer + | undefined; + readonly offset?: number | undefined; + readonly limit?: number | undefined; + } = {}, +): EvidenceLinkInspection => { + const traversal = options.traversal ?? "direct"; + const linkStatus = options.linkStatus ?? "missing"; + const offset = options.offset ?? 0; + const limit = options.limit ?? EVIDENCE_LINK_INSPECTION_LIMIT; + const retained = new Map( + bundle.records.map((record) => [record.evidence_id, record]), + ); + const examined = new Set(); + const pending: string[] = []; + for (const { evidence } of matchingEvidenceRecords(bundle, filters)) + pending.push(evidence.evidence_id); + const matching = pending.length; + while (pending.length > 0) { + const id = pending.pop(); + if (id === undefined || examined.has(id)) continue; + examined.add(id); + if (traversal === "direct") continue; + for (const link of retained.get(id)?.evidence_links ?? []) + if (retained.has(link) && !examined.has(link)) pending.push(link); + } + const rows: EvidenceLinkInspection["rows"] = []; + const counts = { total: 0, retained: 0, missing: 0 }; + let matchingLinks = 0; + // Bundle order is canonical, so a page stays stable across requests. + for (const evidence of bundle.records) { + const id = evidence.evidence_id; + if (!examined.has(id)) continue; + for (const link of directLinksOf(evidence)) { + const status = retained.has(link) ? "retained" : "missing"; + counts.total += 1; + counts[status] += 1; + if (linkStatus !== "all" && linkStatus !== status) continue; + if (matchingLinks >= offset && rows.length < limit) + rows.push({ evidence_id: id, linked_evidence_id: link, status }); + matchingLinks += 1; + } + } + const end = offset + rows.length; + return { + kind: "evidence-link-inspection", + filters: selectedFilters(filters), + traversal, + link_status: linkStatus, + total_retained_records: bundle.records.length, + matching_records: matching, + examined_records: examined.size, + links: counts, + // Nothing selected, or a direct read, supports no claim about a closure. + retained_closure: + traversal === "direct" || examined.size === 0 + ? null + : counts.missing === 0 + ? "complete" + : "incomplete", + offset, + limit, + matching_links: matchingLinks, + next_offset: end < matchingLinks ? end : null, + rows, + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }; +}; diff --git a/src/domain/evidenceReference.ts b/src/domain/evidenceReference.ts index 51aed239d7..c4efba9230 100644 --- a/src/domain/evidenceReference.ts +++ b/src/domain/evidenceReference.ts @@ -4,6 +4,7 @@ import { absoluteLocalPathStringSchema } from "./localPath.js"; import { declareBorrowedEvidenceInput, declareEvidenceInput, + declareEvidenceReferenceAlternative, } from "./evidenceInputContract.js"; /** Exact reference to Evidence already retained by the current session. */ @@ -49,8 +50,7 @@ export const borrowEvidenceArray = () => { }; /** Evidence input, or one record read from a local Evidence file or bundle. */ -export const evidenceSourceInputSchema = z.union([ - ...evidenceInputSchema.options, - evidenceFileReferenceSchema, -]); +export const evidenceSourceInputSchema = declareEvidenceReferenceAlternative( + z.union([...evidenceInputSchema.options, evidenceFileReferenceSchema]), +); export type EvidenceSourceInput = z.output; diff --git a/src/domain/evidenceSubject.ts b/src/domain/evidenceSubject.ts index 5c0994b32c..d9876742e3 100644 --- a/src/domain/evidenceSubject.ts +++ b/src/domain/evidenceSubject.ts @@ -27,6 +27,7 @@ export const evidenceSubjectFormatSchema = z.enum([ "ines", "godot-pck", "unityfs", + "wxapkg", "uimage", "dmg", "pkg", diff --git a/src/domain/explicitSensitiveFailure.test.ts b/src/domain/explicitSensitiveFailure.test.ts index c02093672e..3a1d81b1f7 100644 --- a/src/domain/explicitSensitiveFailure.test.ts +++ b/src/domain/explicitSensitiveFailure.test.ts @@ -278,6 +278,23 @@ it("coarsens ordinary sensitive issue-path segments to their real parent", () => expect(JSON.stringify(projectAnalysisError(result))).not.toContain("secret"); }); +it("retains input retry guidance while redacting marked text", () => { + const error = new AnalysisInputError( + "inspect_web_network_capture", + { + userMessage: "Selected private-marker capture is a symlink", + remediationAction: "Retry /resolved/private-marker/capture.har", + conformsToInputContract: true, + }, + [{ path: ["capture_path"], reason: "invalid_format" }], + ); + const redacted = redactExplicitFailure(error, ["private-marker"]); + const projected = projectAnalysisError(redacted); + expect(projected.remediation.action).toContain("/resolved/"); + expect(projected).not.toHaveProperty("recovery"); + expect(JSON.stringify(projected)).not.toContain("private-marker"); +}); + it("does not reinterpret an unknown argument name as a JSON pointer", () => { const input = new AnalysisInputError( "inspect_web_network_capture", diff --git a/src/domain/explicitSensitiveFailure.ts b/src/domain/explicitSensitiveFailure.ts index e55be448a7..a7e4e5f226 100644 --- a/src/domain/explicitSensitiveFailure.ts +++ b/src/domain/explicitSensitiveFailure.ts @@ -258,7 +258,16 @@ const redactFailure = ( if (error instanceof AnalysisInputError) return new AnalysisInputError( error.operation, - context, + { + ...context, + ...(error.userMessage === undefined + ? {} + : { userMessage: text(error.userMessage) }), + ...(error.remediationAction === undefined + ? {} + : { remediationAction: text(error.remediationAction) }), + conformsToInputContract: error.conformsToInputContract, + }, error.issues.map(issueProjection), ); if (error instanceof AnalysisOutputError) diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index d1f00ed5a1..0bd925fbab 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -142,7 +142,7 @@ export const flutterResultSchemas = { .string() .nullable() .describe( - "Labeled 'Dart VM version' string, when a build carries one", + "Observed string matching the labeled 'Dart VM version' or unlabeled Dart Version::String() pattern; unlabeled matches are candidates rather than verified source identities", ), toolchain_lines: z.array(z.string().min(1)), /** Dart labels omitted by grammar or single-version projection bounds. */ diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 9bc297e78b..1e52571bb1 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -43,6 +43,7 @@ export const harmonyApplicationProjectionResultSchema = z.strictObject({ javascript: z.array(projectedComponentSchema), signing: z.array(projectedComponentSchema), }), + unclassified_components: z.array(projectedComponentSchema), unavailable_components: z.array(unavailableComponentSchema), app_pack_children: z.array( z.union([projectedComponentSchema, unavailableComponentSchema]), @@ -97,6 +98,10 @@ export const projectHarmonyInventory = ( } const manifests = classifyManifests(fileEntries); const classified = classify(all, manifests.components); + const classifiedFiles = new Set([ + ...Object.values(classified).flat(), + ...children, + ]); const bridgeProjection = bridgeCandidates( classified.bytecode, classified.native_libraries, @@ -138,6 +143,9 @@ export const projectHarmonyInventory = ( .sort(compareUnicodeCodePoints), packaging_model: manifests.packagingModel, components: classified, + unclassified_components: all.filter( + (component) => !classifiedFiles.has(component), + ), unavailable_components: unavailable, app_pack_children: children, runtime_families: runtimeFamilies(all, classified), diff --git a/src/domain/javascript/electronCallPathBlockPrefixes.test.ts b/src/domain/javascript/electronCallPathBlockPrefixes.test.ts index 7834d552a4..ce66f1b771 100644 --- a/src/domain/javascript/electronCallPathBlockPrefixes.test.ts +++ b/src/domain/javascript/electronCallPathBlockPrefixes.test.ts @@ -2,7 +2,10 @@ import * as t from "@babel/types"; import { expect, it } from "vitest"; import { ElectronCallPathBlockPrefixes } from "./electronCallPathBlockPrefixes.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; -import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; it("indexes a real flat block once, retaining contextual guards in source order", () => { const parsed = parseJavaScriptSource(`{ @@ -94,3 +97,119 @@ it("charges nested exit checks to the same budget and does not cache a partial i entries: [{ fact: { kind: "branch", consequentExits: true } }], }); }); + +it("retains labelled-jump and budget semantics when scanning loop children", () => { + const parsed = parseJavaScriptSource(`guard: { + if (outer) { + while (again) { if (leave) break guard; } + throw 0; + } + if (inner) { + while (again) { local: { if (leave) break local; } } + throw 0; + } + send("tail"); + }`); + if (!parsed) throw new Error("Expected parsed program"); + const label = parsed.program.body[0]; + if (!t.isLabeledStatement(label) || !t.isBlockStatement(label.body)) + throw new Error("Expected labelled block"); + const block = label.body; + const tail = block.body.at(-1); + if (!tail) throw new Error("Expected terminal statement"); + const parents = new WeakMap(); + traverseJavaScriptAst(parsed.program, { + enter(node, parent) { + if (parent) parents.set(node, parent); + }, + }); + + let visits = 0; + const complete = new ElectronCallPathBlockPrefixes(() => { + visits += 1; + return true; + }, parents); + const result = completeSemanticSteps(complete.before(block, tail)); + expect(result).toMatchObject({ + before: 2, + entries: [ + { position: 0, fact: { kind: "branch", consequentExits: true } }, + { position: 1, fact: { kind: "branch", consequentExits: true } }, + ], + }); + const noParents = new ElectronCallPathBlockPrefixes(() => true); + const unknownLabels = completeSemanticSteps(noParents.before(block, tail)); + expect(unknownLabels).toMatchObject({ + entries: [ + { fact: { kind: "branch", consequentExits: false } }, + { fact: { kind: "branch", consequentExits: false } }, + ], + }); + // This count is characterized on the unchanged base: changing child + // ordering or budget charging must not silently shift the threshold. + expect(visits).toBe(35); + const requiredVisits = 35; + let remaining = requiredVisits - 1; + const limited = new ElectronCallPathBlockPrefixes( + () => --remaining >= 0, + parents, + ); + expect(completeSemanticSteps(limited.before(block, tail))).toBeUndefined(); + remaining = requiredVisits; + expect(completeSemanticSteps(limited.before(block, tail))).toEqual(result); + expect(remaining).toBeGreaterThanOrEqual(0); +}); + +it.each([ + [126, true], + [127, false], +])( + "retains the conservative loop-child depth boundary at %i wrappers", + (wrappers, exits) => { + let expression: t.Expression = t.identifier("condition"); + for (let index = 0; index < wrappers; index += 1) + expression = t.unaryExpression("!", expression); + const branch = t.ifStatement( + t.identifier("selected"), + t.blockStatement([ + t.whileStatement(expression, t.blockStatement([])), + t.throwStatement(t.numericLiteral(0)), + ]), + ); + const block = t.blockStatement([branch]); + const prefixes = new ElectronCallPathBlockPrefixes(() => true); + expect(prefixes.statement(branch, block)).toMatchObject({ + kind: "branch", + consequentExits: exits, + }); + }, +); + +it("does not charge or enter nested function and class bodies during loop scans", () => { + const inspect = (size: number) => { + const parsed = parseJavaScriptSource(`{ + if (selected) { + while (again) { + function nested() { ${"0;".repeat(size)} } + class Nested { method() { ${"0;".repeat(size)} } } + } + throw 0; + } + }`); + const block = parsed?.program.body[0]; + if (!t.isBlockStatement(block) || !t.isIfStatement(block.body[0])) + throw new Error("Expected parsed branch"); + let visits = 0; + const prefixes = new ElectronCallPathBlockPrefixes(() => { + visits += 1; + return true; + }); + const fact = prefixes.statement(block.body[0], block); + return { visits, fact }; + }; + const small = inspect(1); + const large = inspect(300); + expect(small.fact).toMatchObject({ kind: "branch", consequentExits: true }); + expect(large.fact).toMatchObject({ kind: "branch", consequentExits: true }); + expect(large.visits).toBe(small.visits); +}); diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts index 5df4809b64..a5aa5f2b2a 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts @@ -289,8 +289,18 @@ describe("Electron protocol registrations without a scheme", () => { describe("Electron protocol calls that register nothing", () => { it.each([ - ["a handler without a callback", 'protocol.handle("app");'], - ["a handler with a null callback", 'protocol.handle("app", null);'], + [ + "a legacy handler without a callback", + 'protocol.registerStringProtocol("app");', + ], + [ + "a legacy handler with a null callback", + 'protocol.registerStringProtocol("app", null);', + ], + [ + "a legacy handler with a void callback", + 'protocol.registerStringProtocol("app", void 0);', + ], [ "an interceptor without a callback", 'protocol.interceptHttpProtocol("https");', diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.ts b/src/domain/javascript/electronStaticAnalysisProtocol.ts index 8667c9da2b..d6887e78ea 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.ts @@ -87,7 +87,12 @@ export const inspectElectronProtocolNode = ( : INTERCEPTORS.has(target.api) ? "interceptor" : undefined; - if (role !== undefined && !handlerKnownMissing(node)) + // handle installs its own native callback and invokes the supplied handler + // only when a request arrives; a missing handler does not prevent registration. + if ( + role !== undefined && + (target.api === "handle" || !handlerKnownMissing(node)) + ) addProtocol(context, { node, api: target.api, @@ -97,7 +102,7 @@ export const inspectElectronProtocolNode = ( }); }; -/** Electron requires the callback, so a call without one installs nothing. */ +/** Legacy native registration APIs require a callback before installing it. */ const handlerKnownMissing = ( node: t.CallExpression | t.OptionalCallExpression, ): boolean => { diff --git a/src/domain/javascript/javascriptRuntimeLoadState.ts b/src/domain/javascript/javascriptRuntimeLoadState.ts index 275de19d37..e49d60c8f7 100644 --- a/src/domain/javascript/javascriptRuntimeLoadState.ts +++ b/src/domain/javascript/javascriptRuntimeLoadState.ts @@ -57,7 +57,10 @@ const classifyLayer = ( const direct = new Map(); const ambiguous = new Set(); const incomplete = new Set(); - for (const match of matches) + // A target's entry location establishes correspondence, not script presence. + for (const match of matches.filter( + ({ entity_kind }) => entity_kind === "script", + )) if ( match.status === "matched" && match.static_layer_id === layer.layerId && diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index b0be2de5bd..613d47175c 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -94,7 +94,10 @@ const targetEntity = ( frameKey: null, scriptKey: null, location, - staticKind: "renderer", + staticKind: + capture.kind === "v8-inspector" && target.type === "node" + ? "javascript" + : "renderer", sourceSha256: null, label: target.title, properties: { diff --git a/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts index 5adc82e75a..38741665e0 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts @@ -677,3 +677,58 @@ it("retains an earlier captured function after its object method is replaced", ( { kind: "acquire", method: "open" }, ]); }); + +it.each([ + "import { readFile } from 'node:fs/promises'; readFile('config.json');", + "import fs from 'node:fs/promises'; fs.readFile('config.json');", + "import fs from 'node:fs'; fs.promises.readFile('config.json');", + "const { readFile } = require('fs/promises'); readFile('config.json');", + "const fsp = require('node:fs/promises'); fsp.readFile('config.json');", + "const fs = require('node:fs'); fs.promises.readFile('config.json');", +])( + "recovers configuration file reads from promise-based fs APIs: %s", + (source) => { + const ir = analyzeCommonJsSemanticFixture(source); + expect(ir.configurationOperations).toMatchObject([ + { + kind: "file", + key: "config.json", + resolution: "complete", + }, + ]); + }, +); + +it("recovers partial configuration file reads from promise-based fs APIs with dynamic path", () => { + const ir = analyzeCommonJsSemanticFixture( + "import { readFile } from 'node:fs/promises'; readFile(getConfigPath());", + ); + expect(ir.configurationOperations).toMatchObject([ + { + kind: "file", + key: null, + resolution: "partial", + }, + ]); +}); + +it("invalidates promise-based configuration file reads after mutation", () => { + const ir = analyzeCommonJsSemanticFixture( + "const fs = require('fs'); fs.promises.readFile = custom; const fsp = require('fs/promises'); fsp.readFile('wrong');", + ); + expect(ir.configurationOperations).toEqual([]); +}); + +it.each([ + "const { readFileSync } = require('fs/promises'); readFileSync('config.json');", + "const { readFileSync } = require('node:fs/promises'); readFileSync('config.json');", + "import { readFileSync } from 'node:fs/promises'; readFileSync('config.json');", + "import fs from 'node:fs/promises'; fs.readFileSync('config.json');", + "const fs = require('node:fs'); fs.promises.readFileSync('config.json');", +])( + "does not treat nonexistent readFileSync on promise-based fs as configuration read: %s", + (source) => { + const ir = analyzeCommonJsSemanticFixture(source); + expect(ir.configurationOperations).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.child_process_options_shape.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.child_process_options_shape.test.ts new file mode 100644 index 0000000000..9cedf23661 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.child_process_options_shape.test.ts @@ -0,0 +1,109 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Node validates the child_process options object before creating anything, so a + * literal primitive or a literal array in that slot throws + * ERR_INVALID_ARG_TYPE synchronously and no child process exists. `spawn` also + * rejects `null` (`undefined` is its only exemption); `fork` and `execFile` skip + * any nullish options, and `exec` spreads its options and rejects none. + */ +const spawnsFor = (source: string) => + analyzeJavaScriptSemantics( + `import { spawn, exec, execFile, fork } from "node:child_process"; ${source};`, + ).childProcessSpawns; + +it.each([ + ["a string args slot", `spawn("git", "status")`], + ["a numeric args slot", `spawn("git", 1)`], + ["a string options slot", `spawn("git", [], "pipe")`], + ["a null options slot", `spawn("git", [], null)`], + ["an array options slot", `spawn("git", [], [])`], + ["a numeric options slot", `spawn("git", [], 5)`], + ["a string execFile args slot", `execFile("git", "status")`], + ["a string fork args slot", `fork("worker.js", "status")`], + ["a string fork options slot", `fork("worker.js", [], "pipe")`], + ["a BigInt options slot", `spawn("git", [], 0n)`], + ["a negated-zero options slot", `spawn("git", [], -0)`], + ["an overflowing numeric options slot", `spawn("git", [], 1e400)`], + ["a negated numeric options slot", `spawn("git", [], -5)`], + ["a BigInt args slot", `spawn("git", 0n)`], +] as const)("emits no child-process fact for %s", (_name, call) => { + expect(spawnsFor(call)).toEqual([]); +}); + +it.each([ + ["an object options slot", `spawn("tool", [], { stdio: "pipe" })`], + ["an omitted options slot", `spawn("tool", [], undefined)`], + ["options in the second slot", `spawn("tool", { stdio: "pipe" })`], + ["an unresolved options value", `spawn("tool", [], options)`], + // `void ` evaluates to undefined, the one value Node accepts here. + ["a void options slot", `spawn("tool", [], void 0)`], + ["a void expression with an operand", `spawn("tool", [], void 5)`], +] as const)("keeps the spawn for %s", (_name, call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +/** fork and execFile skip a nullish options object; exec validates nothing. */ +it.each([ + ["fork with null options", `fork("worker.js", [], null)`], + ["fork with a void options slot", `fork("worker.js", [], void 0)`], + ["execFile with null options", `execFile("tool", [], null)`], + ["execFile with a void options slot", `execFile("tool", [], void 0)`], + ["exec with a string second argument", `exec("tool", "inherit")`], +] as const)("keeps %s", (_name, call) => { + expect(spawnsFor(call)).toHaveLength(1); +}); + +/** `!` and `~` yield a primitive whatever their operand, so Node rejects them. */ +it.each([ + ["a logical-not options slot", `spawn("tool", [], !0)`], + ["a bitwise-not options slot", `spawn("tool", [], ~5)`], + ["a unary-plus options slot", `spawn("tool", [], +5)`], +] as const)("emits no child-process fact for %s", (_name, call) => { + expect(spawnsFor(call)).toEqual([]); +}); + +/** + * An unshadowed `NaN` or `Infinity` in the options slot is a number, and Node + * rejects a number there exactly as it rejects any other primitive: + * + * spawn("e", NaN) -> THREW ERR_INVALID_ARG_TYPE + * spawn("e", Infinity) -> THREW ERR_INVALID_ARG_TYPE + * execFile("e", NaN) -> THREW ERR_INVALID_ARG_TYPE + * fork("x.js", NaN) -> THREW ERR_INVALID_ARG_TYPE + * exec("e", NaN) -> ok, because exec validates no options + * spawn("e", undefined) -> ok, the one accepted global + */ +it.each([ + ["a NaN options slot for spawn", `spawn("tool", NaN)`], + ["an Infinity options slot for spawn", `spawn("tool", Infinity)`], + ["a NaN third-slot options for spawn", `spawn("tool", [], NaN)`], + ["a NaN options slot for execFile", `execFile("tool", NaN)`], + ["an Infinity options slot for execFile", `execFile("tool", Infinity)`], + ["a NaN options slot for fork", `fork("tool.js", NaN)`], + ["an Infinity options slot for fork", `fork("tool.js", Infinity)`], +] as const)("emits no child-process fact for %s", (_name, call) => { + expect(spawnsFor(call)).toEqual([]); +}); + +it.each([ + ["exec not validating its NaN options", `exec("tool", NaN)`, "exec"], + [ + "exec not validating its Infinity options", + `exec("tool", Infinity)`, + "exec", + ], + ["spawn accepting undefined", `spawn("tool", undefined)`, "spawn"], + ["spawn accepting an object first slot", `spawn("tool", {})`, "spawn"], + ["spawn accepting an array", `spawn("tool", [])`, "spawn"], +] as const)("keeps the child-process fact for %s", (_name, call, method) => { + expect(spawnsFor(call).map(({ method: actual }) => actual)).toEqual([method]); +}); + +it("keeps the fact when a local binding shadows NaN", () => { + expect( + spawnsFor(`const NaN = {}; spawn("tool", NaN)`).map(({ method }) => method), + ).toEqual(["spawn"]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts index 0d3e845a3d..fb9e369ec8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.fork_stdio_ipc.test.ts @@ -37,6 +37,18 @@ it.each([ ]); }); +/** + * Exactly one channel is required: naming `"ipc"` twice throws ERR_IPC_ONE_PIPE + * for the same synchronous reason, so those calls are not spawns either. + */ +it.each([ + ["ipc twice", `["ipc", "ipc"]`], + ["three ipc entries", `["ipc", "ipc", "ipc"]`], + ["a pipe between two ipc entries", `["pipe", "ipc", "ipc"]`], +] as const)("emits no fork for a stdio array with %s", (_name, stdio) => { + expect(spawnsFor(`fork("worker.js", { stdio: ${stdio} })`)).toEqual([]); +}); + it.each([ ["a string stdio", `fork("worker.js", { stdio: "pipe" })`], ["omitted stdio", `fork("worker.js", {})`], diff --git a/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts new file mode 100644 index 0000000000..cd1106b3a0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.open_flag_literal.test.ts @@ -0,0 +1,135 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * `fs.open` and `fs.openSync` validate a literal flag string before touching the + * filesystem and throw `ERR_INVALID_ARG_VALUE` synchronously, so no descriptor + * exists and no resource is acquired. Node's accepted flag set is measured, not + * guessed: `a`, `a+`, `as`, `as+`, `ax`, `ax+`, `r`, `r+`, `rs`, `rs+`, `sa`, + * `sa+`, `sr`, `sr+`, `w`, `w+`, `wx`, `wx+`, `xa`, `xa+`, `xw`, `xw+`. + * + * A numeric flag is a platform bitmask and a computed value may still be a valid + * flag, so only an exactly readable non-flag string is dropped. + */ +const acquisitionsFor = (call: string) => + analyzeJavaScriptSemantics( + `import { open, openSync, createReadStream } from "node:fs";\nimport { open as openPromise } from "node:fs/promises";\nfunction g(p, cb) { ${call}; }`, + ).resourceOperations.map(({ kind, method }) => ({ kind, method })); + +it.each([ + ["a Python-style read flag", `openSync(p, "rb")`], + ["a Python-style write flag", `openSync(p, "wb")`], + ["an append-binary flag", `openSync(p, "ab")`], + ["a lowercase x flag", `openSync(p, "x")`], + ["a comma flag", `openSync(p, "r,x")`], + ["an empty flag", `openSync(p, "")`], + ["an uppercase flag", `openSync(p, "R")`], + ["a callback-form read flag", `open(p, "rb", cb)`], + ["a promise-form read flag", `openPromise(p, "rb")`], + // Node also rejects every non-string flag that is not a nullish value or a + // usable bitmask integer. + ["a fractional flag", `openSync(p, 1.5)`], + // A value outside the signed 32-bit range cannot be a bitmask at all. + ["an int32-overflow flag", `openSync(p, 2147483648)`], + ["an int32-underflow flag", `openSync(p, -2147483649)`], + ["a negated fractional flag", `openSync(p, -1.5)`], + ["a non-finite numeric flag", `openSync(p, 1e400)`], + ["a NaN flag", `openSync(p, NaN)`], + ["an Infinity flag", `openSync(p, Infinity)`], + ["a BigInt flag", `openSync(p, 0n)`], + ["a true flag", `openSync(p, true)`], + ["a false flag", `openSync(p, false)`], + ["an object flag", `openSync(p, {})`], + ["an array flag", `openSync(p, [])`], + // A unary operator is judged by its result, not by its operand: `!` yields a + // boolean and `~0` yields `-1`. + ["a logical-not flag", `openSync(p, !0)`], +] as const)("emits no acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["a read flag", `openSync(p, "r")`], + ["a read-plus flag", `openSync(p, "r+")`], + ["an append-plus flag", `openSync(p, "a+")`], + ["an exclusive write flag", `openSync(p, "wx")`], + ["a symbolic-link flag", `openSync(p, "sr")`], + ["an omitted flag", `openSync(p)`], + ["a computed flag", `openSync(p, flag)`], + ["a numeric bitmask", `openSync(p, 1)`], + ["a zero bitmask", `openSync(p, 0)`], + ["a large bitmask", `openSync(p, 65536)`], + ["a unary-plus bitmask", `openSync(p, +5)`], + ["an explicit undefined flag", `openSync(p, undefined)`], + ["an explicit null flag", `openSync(p, null)`], + // `void ` is `undefined`, which Node accepts as the default read flag, + // so the operand must never decide the outcome. + ["a void flag over a fraction", `openSync(p, void 1.5)`], + ["a void flag over a string", `openSync(p, void "r")`], + // `-0` is the valid zero bitmask, and `~-1` is `0`. + // The sign does not prove rejection: Node accepts some signed bitmasks such as + // `-2147483648` and rejects others such as `-1`, so an in-range negative is + // deliberately left undecided and keeps its acquisition. + ["a minimum int32 bitmask", `openSync(p, -2147483648)`], + ["a negative power-of-two bitmask", `openSync(p, -1073741824)`], + ["an in-range negative bitmask", `openSync(p, -1)`], + // `~0` is `-1` and `~1` is `-2`. Node happens to reject both, but that is the + // same undecidable signed-bitmask question, so they stay undecided too. + ["a bitwise-not of zero", `openSync(p, ~0)`], + ["a bitwise-not of one", `openSync(p, ~1)`], + ["a maximum int32 bitmask", `openSync(p, 2147483647)`], + ["a negative-zero flag", `openSync(p, -0)`], + ["a negative-zero expression flag", `openSync(p, -0.0)`], + ["a unary-plus zero flag", `openSync(p, +0)`], + ["a bitwise-not of negative one", `openSync(p, ~-1)`], + // A spread before the flag slot shifts the runtime position, so argument 1 is + // not provably the flag: `openSync(...args, "0600")` opens with a mode. + ["a spread before the flag", `openSync(...args, "0600")`], + ["a spread supplying every argument", `openSync(...args)`], + ["a spread in the flag slot", `openSync(p, ...flags)`], +] as const)("keeps the acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); + +it("keeps the callback-form acquisition with an accepted flag", () => { + expect(acquisitionsFor(`open(p, "r", cb)`)).toEqual([ + { kind: "acquire", method: "open" }, + ]); +}); + +/** + * `fs.createReadStream` does not validate the flag synchronously: it returns a + * stream and surfaces the failure on the stream's error event, so the stream + * object is still acquired. + */ +it("keeps a read stream whose flags option is invalid", () => { + expect(acquisitionsFor(`createReadStream(p, { flags: "rb" })`)).toEqual([ + { kind: "acquire", method: "createReadStream" }, + ]); +}); + +/** + * Review finding: a no-expression template literal is exactly readable, so it is the + * same flag string Node receives. + * + * fs.openSync(p, `rb`) -> THREW ERR_INVALID_ARG_VALUE + * fs.openSync(p, `r`) -> ok + */ +it.each([ + ["a rejected template flag", 'openSync("x", `rb`)'], + ["another rejected template flag", 'openSync("x", `bad`)'], +] as const)("emits no fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([]); +}); + +it.each([ + ["an accepted template flag", 'openSync("x", `r`)'], + ["an interpolated template flag", 'openSync("x", `${flag}`)'], +] as const)("keeps the fs acquisition for %s", (_name, call) => { + expect(acquisitionsFor(call)).toEqual([ + { kind: "acquire", method: "openSync" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts new file mode 100644 index 0000000000..df055f1261 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.rejected_promise_executor.test.ts @@ -0,0 +1,90 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Node's `Promise` constructor calls the executor synchronously and throws a + * `TypeError` when it is not callable — before any promise object exists. A + * provable literal that cannot be a function, or a missing argument, is + * therefore not a promise construction. + * + * Measured on Node 24.22.1: + * + * new Promise() / (1) / ("work") / (true) / (null) / (void 0) -> THREW TypeError + * new Promise(1n) / (/re/) / ([]) / ({}) -> THREW TypeError + * new Promise(() => {}) / (function () {}) / (class {}) -> OK Promise + */ +const promisesFor = (source: string) => + analyzeJavaScriptSemantics(source).promiseOperations.map( + ({ kind, method, sourceResolution }) => ({ + kind, + method, + sourceResolution, + }), + ); + +it.each([ + ["no argument", `new Promise()`], + ["a numeric executor", `new Promise(1)`], + ["a negative numeric executor", `new Promise(-1)`], + ["a string executor", `new Promise("work")`], + ["a template-literal executor", "new Promise(`work`)"], + ["a boolean executor", `new Promise(true)`], + ["a negated boolean executor", `new Promise(!0)`], + ["a null executor", `new Promise(null)`], + ["a void executor", `new Promise(void 1)`], + ["a BigInt executor", `new Promise(1n)`], + ["a RegExp executor", `new Promise(/re/)`], + // Babel stores an overflowing numeric literal as `Infinity`, which is not + // callable either. + ["an overflowing numeric executor", `new Promise(1e309)`], + ["an Infinity executor", `new Promise(Infinity)`], + ["a NaN executor", `new Promise(NaN)`], + ["an array executor", `new Promise([])`], + ["an object executor", `new Promise({})`], +] as const)("emits no promise for %s", (_name, source) => { + expect(promisesFor(source + ";")).toEqual([]); +}); + +it.each([ + ["an arrow executor", `new Promise(() => {})`], + ["an async arrow executor", `new Promise(async () => {})`], + ["a function expression executor", `new Promise(function () {})`], + ["a class expression executor", `new Promise(class {})`], +] as const)("keeps the promise for %s", (_name, source) => { + expect(promisesFor(source + ";")).toHaveLength(1); +}); + +it("keeps a promise whose executor is an identifier", () => { + expect(promisesFor(`const p = new Promise(run);`)).toEqual([ + { kind: "constructor", method: "new", sourceResolution: "complete" }, + ]); +}); + +it("keeps a promise whose executor is a call result", () => { + expect(promisesFor(`const p = new Promise(makeExecutor());`)).toHaveLength(1); +}); + +it("keeps a promise whose executor is a spread", () => { + expect(promisesFor(`const p = new Promise(...list);`)).toHaveLength(1); +}); + +/** + * Filtering happens at the candidate-push site rather than inside + * `promiseCandidateDetails`, which also decides whether `await` becomes an + * `awaited-expression`. A rejected executor must produce neither fact. + */ +it("emits no awaited-expression task for an awaited rejected executor", () => { + expect(promisesFor(`async function f() { await new Promise(1); }`)).toEqual( + [], + ); +}); + +it("keeps the awaited ownership for a callable executor under await", () => { + const operations = analyzeJavaScriptSemantics( + `async function f() { await new Promise(() => {}); }`, + ).promiseOperations; + expect(operations).toHaveLength(1); + expect(operations[0]?.kind).toBe("constructor"); + expect(operations[0]?.ownership).toBe("awaited"); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts new file mode 100644 index 0000000000..26ef076033 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.response_body_readers.test.ts @@ -0,0 +1,84 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * Only `fetch` resolves to a `Response`, and only once its promise settles. + * `http.request`/`http.get` return a `ClientRequest` and `new WebSocket` a + * socket; neither exposes `json`/`text`/`arrayBuffer`, and an un-awaited + * `fetch` returns a `Promise`. In every one of those the call throws at the call + * site instead of reading a body, so it is not a response consumer. + */ +const consumersFor = (source: string) => + analyzeJavaScriptSemantics(source) + .requestOperations.filter(({ kind }) => kind === "response-consumer") + .map(({ method, endpoint, resolution }) => ({ + method, + endpoint, + resolution, + })); + +it.each([ + [ + "an un-awaited fetch", + `async function f(){ const res = fetch("https://api.test/x"); return res.json(); }`, + ], + [ + "an http.get request", + `import http from "node:http"; + function f(){ const req = http.get("https://api.test/x"); return req.json(); }`, + ], + [ + "an http.request request", + `import http from "node:http"; + function f(){ const req = http.request("https://api.test/x"); return req.text(); }`, + ], + [ + "a WebSocket handshake", + `function f(){ const ws = new WebSocket("wss://api.test/x"); return ws.json(); }`, + ], +])("emits no response consumer for %s", (_name, source) => { + expect(consumersFor(source)).toEqual([]); +}); + +it.each([ + ["json", "json"], + ["text", "text"], + ["arrayBuffer", "arrayBuffer"], +] as const)("keeps the settled fetch body reader for %s", (_name, method) => { + expect( + consumersFor( + `async function f(){ const res = await fetch("https://api.test/x"); return res.${method}(); }`, + ), + ).toEqual([ + { + method, + endpoint: "https://api.test/x", + resolution: "complete", + }, + ]); +}); + +it("keeps a settled fetch reader through a wrapper expression", () => { + expect( + consumersFor( + `async function f(){ const res = await fetch("https://api.test/x"); return (res).json(); }`, + ), + ).toHaveLength(1); +}); + +it("keeps the fetch candidate when the binding also holds another request", () => { + // The mixed binding keeps its fetch link, so the consumer stays a candidate + // rather than losing the endpoint entirely. + const consumers = consumersFor(` + import http from "node:http"; + async function f(){ + let res = await fetch("https://api.test/x"); + res = await http.get("https://api.test/y"); + return res.json(); + } + `); + expect(consumers).toEqual([ + { method: "json", endpoint: null, resolution: "partial" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts new file mode 100644 index 0000000000..3587a2c7d0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.timer_callbacks.test.ts @@ -0,0 +1,162 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +/** + * A schedule imported from Node's `timers` module requires a function in + * argument 0. Node validates it before creating a handle, so a provable literal + * that is not a function throws `ERR_INVALID_ARG_TYPE: The "callback" argument + * must be of type function` synchronously and no timer exists. + * + * Only exactly readable literals are decided: an identifier or a call may still + * be a function at runtime. + */ +const nodeTimersCalls = (call: string) => + analyzeJavaScriptSemantics( + `import { setTimeout, setInterval, setImmediate } from "node:timers"; function g(fn) { ${call}; }`, + ).timerOperations.map(({ kind, method }) => ({ kind, method })); + +/** A bare global timer may belong to a browser, where a string is evaluated. */ +const globalTimerCalls = (call: string) => + analyzeJavaScriptSemantics(`function g(fn) { ${call}; }`).timerOperations.map( + ({ kind, method }) => ({ kind, method }), + ); + +it.each([ + ["a string callback", `setTimeout("code", 0)`], + ["a template-literal callback", "setTimeout(`code`, 0)"], + ["a numeric callback", `setTimeout(1, 0)`], + ["a BigInt callback", `setTimeout(1n, 0)`], + ["a boolean callback", `setTimeout(true, 0)`], + ["a null callback", `setTimeout(null, 0)`], + ["a void callback", `setTimeout(void 0, 0)`], + ["an array callback", `setTimeout([], 0)`], + ["an object callback", `setTimeout({}, 0)`], + ["no arguments at all", `setTimeout()`], + ["a setImmediate string callback", `setImmediate("code")`], + ["a setInterval string callback", `setInterval("code", 0)`], +] as const)("emits no node timers schedule for %s", (_name, call) => { + expect(nodeTimersCalls(call)).toEqual([]); +}); + +it.each([ + ["a function expression", `setTimeout(function () {}, 0)`, "setTimeout"], + ["an arrow function", `setTimeout(() => {}, 0)`, "setTimeout"], + ["an identifier", `setTimeout(fn, 0)`, "setTimeout"], + ["an identifier without a delay", `setTimeout(fn)`, "setTimeout"], + ["a call result", `setTimeout(select(), 0)`, "setTimeout"], + ["a class expression", `setTimeout(class {}, 0)`, "setTimeout"], + ["a resolved setImmediate", `setImmediate(fn)`, "setImmediate"], + ["a resolved setInterval", `setInterval(fn, 10)`, "setInterval"], +] as const)("keeps the node timers schedule for %s", (_name, call, method) => { + expect(nodeTimersCalls(call)).toEqual([{ kind: "schedule", method }]); +}); + +/** + * Browser and Electron renderer sources reach this analysis without a + * Node/browser distinction, and there a string callback is evaluated by the + * host. A bare global timer therefore keeps its schedule whatever the argument. + */ +it.each([ + ["a string callback", `setTimeout("code", 0)`], + ["a string setInterval callback", `setInterval("code", 0)`], + ["a null callback", `setTimeout(null, 0)`], +] as const)("keeps the bare global timer for %s", (_name, call) => { + expect(globalTimerCalls(call)).toHaveLength(1); +}); + +it("keeps a cancellation whose handle is a real timer", () => { + const operations = analyzeJavaScriptSemantics(` + import { setTimeout } from "node:timers"; + const handle = setTimeout(() => {}, 10); + clearTimeout(handle); + `).timerOperations; + expect(operations.map(({ kind }) => kind)).toEqual(["schedule", "cancel"]); + expect(operations[1]?.resolution).toBe("complete"); +}); + +/** + * `undefined`, `NaN` and `Infinity` are unmodified globals and none is a + * function, and every unary operator yields a primitive rather than a function. + * Node rejects each before creating a handle: + * + * setTimeout(undefined) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(NaN) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(Infinity) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(!0) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(-1) -> THREW ERR_INVALID_ARG_TYPE + * setTimeout(typeof 1) -> THREW ERR_INVALID_ARG_TYPE + * --- still scheduled --- + * setTimeout(() => {}) -> ok + * setTimeout(class {}) -> ok, a class passes Node's typeof check + */ +it.each([ + ["an undefined callback", `setTimeout(undefined)`], + ["a NaN callback", `setTimeout(NaN)`], + ["an Infinity callback", `setTimeout(Infinity)`], + ["a void callback", `setTimeout(void 0)`], + ["a logical-not callback", `setTimeout(!0)`], + ["a bitwise-not callback", `setTimeout(~0)`], + ["a negated callback", `setTimeout(-1)`], + ["a unary-plus callback", `setTimeout(+"1")`], + ["a typeof callback", `setTimeout(typeof 1)`], + ["a NaN immediate callback", `setImmediate(NaN)`], + ["an Infinity interval callback", `setInterval(Infinity)`], +] as const)("emits no timer for %s", (_name, call) => { + expect(nodeTimersCalls(call)).toEqual([]); +}); + +it.each([ + ["an arrow callback", `setTimeout(() => {})`, "setTimeout"], + ["an identifier callback", `setTimeout(handler)`, "setTimeout"], + ["a class callback", `setTimeout(class {})`, "setTimeout"], + ["an immediate arrow callback", `setImmediate(() => {})`, "setImmediate"], +] as const)("keeps the timer for %s", (_name, call, method) => { + expect(nodeTimersCalls(call)).toEqual([{ kind: "schedule", method }]); +}); + +it("keeps the timer when a local binding shadows the global", () => { + expect(nodeTimersCalls(`const NaN = () => {}; setTimeout(NaN)`)).toEqual([ + { kind: "schedule", method: "setTimeout" }, + ]); + expect( + nodeTimersCalls(`const undefined = () => {}; setTimeout(undefined)`), + ).toEqual([{ kind: "schedule", method: "setTimeout" }]); +}); + +/** + * Sloppy `eval` can introduce a local binding named `undefined`, `NaN` or + * `Infinity`, which then shadows the global the guard would otherwise read. The + * opaque-effects owner has to agree before the global reading is conclusive: + * + * function g() { eval("var undefined = () => {}"); setTimeout(undefined, 0); } + * -> schedules a timer in Node (sloppy script scope) + * + * In a module the same `eval` is strict and introduces no outer binding, so the + * global reading still holds. + */ +const sloppyTimersCalls = (call: string) => + analyzeJavaScriptSemantics( + `const { setTimeout } = require("node:timers"); function g() { ${call}; }`, + ).timerOperations.map(({ kind, method }) => ({ kind, method })); + +it.each([ + [ + "an eval-introduced undefined", + `eval('var undefined = () => {}'); setTimeout(undefined, 0)`, + ], + ["an eval-introduced NaN", `eval('var NaN = () => {}'); setTimeout(NaN, 0)`], + [ + "an eval-introduced Infinity", + `eval('var Infinity = () => {}'); setTimeout(Infinity, 0)`, + ], +] as const)("keeps the timer after %s", (_name, call) => { + expect(sloppyTimersCalls(call)).toEqual([ + { kind: "schedule", method: "setTimeout" }, + ]); +}); + +it("still drops a global callback when no eval can shadow it", () => { + expect(sloppyTimersCalls(`setTimeout(undefined, 0)`)).toEqual([]); + expect(sloppyTimersCalls(`setTimeout(NaN, 0)`)).toEqual([]); +}); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index ea9e990bd1..4c5d56fa4b 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -21,6 +21,7 @@ import { import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; +import { semanticOpaqueNameMayHaveChanged } from "./javascriptSemanticOpaqueEffects.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { builtinDataEffectMethod, @@ -286,10 +287,79 @@ const emitterIdentity = ( }; }; +/** + * A timer proven to come from the Node `timers` module rather than a bare + * global. Browser and Electron renderer sources reach this analysis without a + * Node/browser distinction, and there `setTimeout("refresh()", 0)` is a real + * timer: the string is evaluated by the host. Node's callback rule therefore + * applies only when the timer is imported from `node:timers`, where that form + * cannot exist. + */ +const nodeBuiltinTimerMethod = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptSemanticTimerOperation["method"] | null => + admittedTimerMethod( + builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", + ); + +/** + * A schedule imported from Node's `timers` module requires a function in + * argument 0. Node validates it before creating a handle, so a provable literal + * that is not a function throws ERR_INVALID_ARG_TYPE synchronously and no timer + * exists. Only literals the analyzer can read exactly are decided; an identifier + * or call may still be a function at runtime. + */ +const schedulesWithNonFunctionCallback = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const argument = node.arguments[0]; + // No argument at all cannot be a function. A spread may still expand to one. + if (argument === undefined) return true; + if (t.isSpreadElement(argument)) return false; + const value = unwrapJavaScriptExpression(argument).node; + if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) + return false; + if ( + t.isStringLiteral(value) || + t.isNumericLiteral(value) || + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) || + t.isNullLiteral(value) || + t.isTemplateLiteral(value) || + t.isArrayExpression(value) || + t.isObjectExpression(value) + ) + return true; + // `undefined`, `NaN` and `Infinity` are unmodified globals, and none is a + // function. A shadowed binding may still hold one, so only the global counts. + if ( + t.isIdentifier(value) && + ["undefined", "NaN", "Infinity"].includes(value.name) && + isUnmodifiedGlobalReference(value, state, value.name) && + !semanticOpaqueNameMayHaveChanged(value, state) + ) + return true; + // A class passes Node's own typeof check; invoking it is a separate failure, + // so the schedule itself still happens. Every other unary operator (including + // `void`, `!`, `~`, `typeof` and `delete`) yields a primitive, never a + // function. + return t.isUnaryExpression(value); +}; + const collectTimer = (input: TimerCollectionInput): void => { const { node, ownerCallableId, state, output, ancestors } = input; const method = timerMethod(node.callee, state); if (method === null) return; + if ( + TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) && + nodeBuiltinTimerMethod(node.callee, state) !== null && + schedulesWithNonFunctionCallback(node, input.state) + ) + return; const kind = TIMER_SCHEDULE_METHODS.some((candidate) => candidate === method) ? "schedule" : "cancel"; diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index ea8c872e19..3b3ad852bd 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -73,7 +73,9 @@ export const collectJavaScriptSemanticChildProcesses = ( ): ChildProcessAnalysis => { const context = createSemanticExpressionShapeResolver(state); const candidates = collectSpawns(program, state, callables).filter( - (candidate) => !forkRejectsStdioArray(candidate, context), + (candidate) => + !forkRejectsStdioArray(candidate, context) && + !rejectsOptionsShape(candidate, context, state), ); const childProcessSpawns = candidates.map((candidate) => immutableSpawn(candidate, context), @@ -450,12 +452,13 @@ const stdioMode = ( }; /** - * Node's `fork` requires an explicit `stdio` array to carry the IPC channel. An - * array that provably omits `"ipc"` throws ERR_CHILD_PROCESS_IPC_REQUIRED - * synchronously, before any child process exists, so it is not a spawn at all. - * A string `stdio` is normalised with `"ipc"` appended, so only the array form - * can omit it; a spread or unreadable element may still supply the channel and - * is therefore left alone. + * Node's `fork` requires an explicit `stdio` array to carry exactly one IPC + * channel. An array that provably omits `"ipc"` throws + * ERR_CHILD_PROCESS_IPC_REQUIRED, and one that names `"ipc"` twice throws + * ERR_IPC_ONE_PIPE; both throws are synchronous, before any child process + * exists, so such a call is not a spawn at all. A string `stdio` is normalised + * with a single `"ipc"` appended, so only the array form can be wrong; a spread + * or unreadable element may still change the count and is therefore left alone. */ const forkRejectsStdioArray = ( candidate: SpawnCandidate, @@ -477,10 +480,63 @@ const forkRejectsStdioArray = ( ); if (literals.some((literal) => literal === undefined || !literal.found)) return false; - return !literals.some( + const ipcCount = literals.filter( (literal) => literal !== undefined && literal.found && literal.value === "ipc", - ); + ).length; + return ipcCount !== 1; +}; + +/** + * Node validates the options object before creating anything, so a literal + * primitive or literal array in that slot throws ERR_INVALID_ARG_TYPE + * synchronously and no child process exists. `spawn` also rejects `null` + * (`undefined` is its only exemption), while `fork` and `execFile` skip any + * nullish options, and `exec` spreads its options and validates none of them. + */ +const rejectsOptionsShape = ( + candidate: SpawnCandidate, + context: ChildSpawnContext, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (candidate.method === "exec") return false; + const { options } = spawnArguments(candidate, context); + if (options === undefined) return false; + const value = unwrapJavaScriptExpression(options).node; + if (context.isArray(value)) return true; + const literal = readExactJavaScriptLiteral(value); + if (literal.found) + return !(candidate.method !== "spawn" && literal.value === null); + // An unshadowed `NaN` or `Infinity` is a number, and Node's options check + // rejects a number the same way it rejects any other primitive. `undefined` + // stays the one accepted global. + if ( + t.isIdentifier(value) && + (value.name === "NaN" || value.name === "Infinity") && + isUnshadowedGlobal(value, state, value.name) + ) + return true; + return isUnpricedPrimitiveLiteral(value); +}; + +/** + * The exact-value reader deliberately refuses to price a BigInt, a non-finite + * numeric literal such as `1e400`, and a negated numeric literal such as `-0`. + * All are primitive literals, so Node's options check rejects them the same way; + * read the shape directly here rather than widening the shared reader. + * + * `void ` is the exception: it evaluates to `undefined`, which is the one + * options value Node accepts in place of an object, so it must not be dropped. + */ +const isUnpricedPrimitiveLiteral = (value: t.Node): boolean => { + if (t.isNumericLiteral(value) || t.isBigIntLiteral(value)) return true; + if (!t.isUnaryExpression(value)) return false; + if (value.operator === "void") return false; + // `!` and `~` always yield a primitive, whatever their operand. + if (value.operator === "!" || value.operator === "~") return true; + if (value.operator !== "+" && value.operator !== "-") return false; + const argument = unwrapJavaScriptExpression(value.argument).node; + return t.isNumericLiteral(argument) || t.isBigIntLiteral(argument); }; /** diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index f2bfa6ee88..4b8385f53a 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -8,6 +8,7 @@ import type { } from "./javascriptSemanticIr.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { + javaScriptExpressionWrapperTarget, readExactJavaScriptLiteral, semanticStaticPropertyKey, semanticStaticPropertyName, @@ -123,11 +124,16 @@ const configurationDetails = ( } if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return null; - const fileMethod = builtinMethod(node.callee, context.state, [ - "fs", - "node:fs", - ]); - return fileMethod === "readFile" || fileMethod === "readFileSync" + const fsMethod = builtinMethod(node.callee, context.state, ["fs", "node:fs"]); + const isFileRead = + fsMethod === "readFile" || + fsMethod === "readFileSync" || + (fsMethod === null && + builtinMethod(node.callee, context.state, [ + "fs/promises", + "node:fs/promises", + ]) === "readFile"); + return isFileRead ? { kind: "file", key: literalString(node.arguments[0]), @@ -299,6 +305,28 @@ const soleWriteReachesRead = ( ); }; +/** + * Only `fetch` resolves to a `Response`, and only once its promise settles. + * `http.request`/`http.get` return a `ClientRequest` and `new WebSocket` a + * socket, neither of which has `json`/`text`/`arrayBuffer`; an un-awaited + * `fetch` returns a `Promise`. In all of those the call throws at the call site + * instead of reading a body, so it is not a response consumer at all. + */ +const settlesToResponse = ( + request: RequestCandidate, + context: TraversalContext, +): boolean => { + if (request.operation.method !== "fetch") return false; + let node: t.Node = request.node; + for (;;) { + const parent = context.state.parentsByNode.get(node); + if (parent === undefined) return false; + if (t.isAwaitExpression(parent)) return true; + if (javaScriptExpressionWrapperTarget(parent) !== node) return false; + node = parent; + } +}; + const collectResponseConsumers = ( program: t.Program, context: TraversalContext, @@ -333,11 +361,20 @@ const collectResponseConsumers = ( member.object, member.object.name, ); - const linked = + const candidates = binding === undefined ? [] : (requestsByBinding.get(binding.bindingId) ?? []); + // A body reader only exists on a settled fetch Response. Requests of any + // other kind throw at this call site instead, so they link nothing; if no + // candidate survives, the call is not a response consumer at all. + const linked = candidates.filter((request) => + settlesToResponse(request, context), + ); if (linked.length === 0) return; + // A binding that also receives a request kind without a body reader leaves + // this reader a candidate, not a proven consumer of the fetch response. + const otherKinds = candidates.length !== linked.length; // A consumer of exactly one request through its own result binding // reads that request's response, so it keeps the request's endpoint. const uniqueRequest = @@ -372,7 +409,8 @@ const collectResponseConsumers = ( .sort(compareUnicodeCodePoints), endpoint: soleWrite ? (uniqueRequest?.operation.endpoint ?? null) : null, fields: [], - resolution: uniqueRequest === undefined ? "partial" : "complete", + resolution: + uniqueRequest === undefined || otherKinds ? "partial" : "complete", }); }); return output; diff --git a/src/domain/javascript/javascriptSemanticPromises.ts b/src/domain/javascript/javascriptSemanticPromises.ts index 250285e458..08515e4e43 100644 --- a/src/domain/javascript/javascriptSemanticPromises.ts +++ b/src/domain/javascript/javascriptSemanticPromises.ts @@ -8,6 +8,7 @@ import type { import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; import { createSemanticCallableIdStack } from "./javascriptSemanticCallableStack.js"; import { + readExactJavaScriptLiteral, semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -100,6 +101,50 @@ export const collectJavaScriptSemanticPromises = ( }); }; +/** + * Node's `Promise` constructor calls the executor synchronously and throws a + * `TypeError` when it is not callable, before any promise object exists. An + * exactly readable literal that cannot be a function — or a missing argument — + * is therefore not a promise construction, while an identifier, a call result or + * a spread may still supply a function and keeps its operation. + * + * A class expression is callable enough to pass Node's own check, so it is kept. + */ +const rejectsPromiseExecutor = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isNewExpression(node)) return false; + const executor = node.arguments[0]; + if (executor === undefined) return true; + if (t.isSpreadElement(executor)) return false; + const value = unwrapJavaScriptExpression(executor).node; + if (t.isFunctionExpression(value) || t.isArrowFunctionExpression(value)) + return false; + if (t.isClassExpression(value)) return false; + // `NaN` and `Infinity` are unshadowed globals rather than literals, and neither + // is callable. + if ( + t.isIdentifier(value) && + (value.name === "NaN" || value.name === "Infinity") && + isUnmodifiedGlobalReference(value, state, value.name) + ) + return true; + // Every unary operator yields a primitive, and a primitive is never callable. + // A numeric literal is read directly rather than through the exact-value + // reader, which refuses to price a non-finite literal such as `1e309`. + return ( + t.isUnaryExpression(value) || + t.isNumericLiteral(value) || + readExactJavaScriptLiteral(value).found || + t.isTemplateLiteral(value) || + t.isBigIntLiteral(value) || + t.isRegExpLiteral(value) || + t.isArrayExpression(value) || + t.isObjectExpression(value) + ); +}; + const collectCandidates = ( program: t.Program, state: JavaScriptSemanticAnalysisState, @@ -115,7 +160,7 @@ const collectCandidates = ( enter: (node, parent) => { callableStack.enter(node, parent); const details = promiseCandidateDetails(node, state); - if (details !== null) { + if (details !== null && !rejectsPromiseExecutor(node, state)) { output.push({ node, ...details, diff --git a/src/domain/javascript/javascriptSemanticQuerySchemas.ts b/src/domain/javascript/javascriptSemanticQuerySchemas.ts index 6002e09c41..fa211e757d 100644 --- a/src/domain/javascript/javascriptSemanticQuerySchemas.ts +++ b/src/domain/javascript/javascriptSemanticQuerySchemas.ts @@ -42,9 +42,11 @@ export const javaScriptSemanticQuerySeedSchema = z.discriminatedUnion("kind", [ kind: z.literal("property"), name: z.string(), }), + // Analysis preserves the complete literal endpoint, including the empty + // string, so the seed matches any retained value exactly, as event does. z.strictObject({ kind: z.literal("endpoint"), - value: z.string().min(1), + value: z.string(), }), z.strictObject({ kind: z.literal("event"), diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index d441c70d1c..1e5534d40a 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -26,7 +26,7 @@ import { traverseDataEffects, type DataEffectTraversalContext, } from "./javascriptSemanticDataEffectHelpers.js"; -import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { FS_OPEN_FLAGS, range } from "./javascriptStaticAnalysisHelpers.js"; const RELEASE_METHODS = ["close", "destroy", "end"] as const; @@ -75,6 +75,90 @@ export const collectJavaScriptSemanticResources = ( ]; }; +/** + * `fs.open`/`fs.openSync` validate the flag before touching the filesystem, so + * a flag Node rejects at the call site throws synchronously and no descriptor + * exists. Node accepts only a known flag string, a nullish value (which defaults + * to `"r"`), or a bitmask integer; everything else throws. A computed value may + * still be valid, so only an exactly readable literal is decided. + */ +const rejectsOpenFlagLiteral = ( + node: t.CallExpression | t.OptionalCallExpression, + context: DataEffectTraversalContext, +): boolean => { + // A spread before the flag slot shifts the runtime position, so argument 1 is + // not provably the flag. `openSync(...args, "0600")` with + // `args = [path, "w"]` opens with mode `"0600"`, not with an invalid flag. + if (t.isSpreadElement(node.arguments[0])) return false; + const flag = node.arguments[1]; + if (flag === undefined || t.isSpreadElement(flag)) return false; + const value = unwrapJavaScriptExpression(flag).node; + // A no-expression template literal is exactly readable, so it is the same flag + // string Node receives. + const flagText = t.isStringLiteral(value) + ? value.value + : t.isTemplateLiteral(value) && value.expressions.length === 0 + ? value.quasis[0]?.value.cooked + : undefined; + if (flagText !== undefined && flagText !== null) + return !FS_OPEN_FLAGS.has(flagText); + // A non-integer or out-of-int32 value is not a usable bitmask. The sign alone + // does not decide: Node accepts some signed bitmasks such as `-2147483648` and + // rejects others such as `-1`, so an in-range negative is left undecided. + const bits = staticFlagBits(value); + if (bits !== undefined) + return ( + bits !== null && + (!Number.isInteger(bits) || bits > 2_147_483_647 || bits < -2_147_483_648) + ); + // `NaN` and `Infinity` are unshadowed globals, and neither is a bitmask. + if ( + t.isIdentifier(value) && + (value.name === "NaN" || value.name === "Infinity") && + isUnmodifiedGlobalReference(value, context.state, value.name) + ) + return true; + // BigInt, boolean, object and array literals all fail Node's flag check. + return ( + t.isBigIntLiteral(value) || + t.isBooleanLiteral(value) || + t.isObjectExpression(value) || + t.isArrayExpression(value) + ); +}; + +/** + * The number a literal flag argument evaluates to, or `null` when it evaluates to + * `undefined`. Returns `undefined` when the value is not exactly readable. + * + * `void ` is `undefined`, which Node accepts as the default read flag, so + * it must never be judged by its operand. `!`, `typeof` and `delete` yield a + * boolean or a string rather than a bitmask. + */ +const staticFlagBits = (value: t.Node): number | null | undefined => { + if (t.isNumericLiteral(value)) return value.value; + if (!t.isUnaryExpression(value)) return undefined; + if (value.operator === "void") return null; + if ( + value.operator === "!" || + value.operator === "typeof" || + value.operator === "delete" + ) + return Number.NaN; + const operand = unwrapJavaScriptExpression(value.argument).node; + if (!t.isNumericLiteral(operand)) return undefined; + if (value.operator === "-") return -operand.value; + if (value.operator === "+") return operand.value; + if (value.operator === "~") return ~operand.value; + return undefined; +}; + +/** `fs.open`, `fs.openSync` and `fs/promises.open` take the flag in slot 1. */ +const openFlagInFirstArgument = (reference: AcquisitionReference): boolean => + reference.method === "open" || reference.method === "openSync" + ? reference.specifier === "fs" || reference.specifier === "fs/promises" + : false; + const collectAcquisitions = ( program: t.Program, context: DataEffectTraversalContext, @@ -84,6 +168,11 @@ const collectAcquisitions = ( if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return; const reference = acquisitionMethod(node.callee, context.state); if (reference === null) return; + if ( + openFlagInFirstArgument(reference) && + rejectsOpenFlagLiteral(node, context) + ) + return; output.push({ node, specifier: reference.specifier, diff --git a/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts index 37be9a1df8..b0b184efa5 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.partialEndpoints.test.ts @@ -10,6 +10,68 @@ const endpoints = (source: string) => resolution: endpoint.resolution, })); +it.each(["window.Request", "globalThis.Request", "self.Request"])( + "resolves an inline literal URL from the built-in %s constructor", + (constructor) => { + expect( + endpoints(`fetch(new ${constructor}("/api/chat", { method: "POST" }));`), + ).toEqual([ + { + kind: "network", + value: "/api/chat", + mechanism: "call:fetch", + resolution: undefined, + }, + ]); + }, +); + +it.each(["globalThis.fetch", "self.fetch", "fetch?."])( + "resolves an inline Request URL passed to %s", + (callee) => { + expect(endpoints(`${callee}(new Request(\`/api/chat\`));`)).toEqual([ + { + kind: "network", + value: "/api/chat", + mechanism: `call:${callee.replace("?.", "")}`, + resolution: undefined, + }, + ]); + }, +); + +it.each([ + [ + "const Request = custom;", + 'fetch(new Request("/api/chat"));', + 'new Request("/api/chat")', + ], + [ + "Request = custom;", + 'fetch(new Request("/api/chat"));', + 'new Request("/api/chat")', + ], + [ + "globalThis.Request = custom;", + 'fetch(new Request("/api/chat"));', + 'new Request("/api/chat")', + ], + [ + "const window = custom;", + 'fetch(new window.Request("/api/chat"));', + 'new window.Request("/api/chat")', + ], + ["", "fetch(new Request(target));", "new Request(target)"], + ["", "fetch(new Request(...urls));", "new Request(...urls)"], +])( + "retains an unproven Request target: %s %s", + (prefix, source, expression) => { + expect(endpoints(prefix + source)).toEqual([ + expect.objectContaining({ value: expression, resolution: "partial" }), + ]); + }, +); + it("resolves unchanged lexical strings and object properties", () => { expect( endpoints(` diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 40a2f4166e..209b3eacdd 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -530,7 +530,7 @@ const WINDOW_OPEN_CALL = /(?:^|\.)(?:window|self|globalThis|top|parent|opener|frames|document)\.open$/u; /** Node `fs.open` flag strings, which are never request URLs. */ -const FS_OPEN_FLAGS = new Set([ +export const FS_OPEN_FLAGS: ReadonlySet = new Set([ "a", "a+", "as", diff --git a/src/domain/liveBinaryTargetSummary.ts b/src/domain/liveBinaryTargetSummary.ts index ab1efe39f5..45b6d9c0da 100644 --- a/src/domain/liveBinaryTargetSummary.ts +++ b/src/domain/liveBinaryTargetSummary.ts @@ -73,6 +73,7 @@ export const liveBinaryTargetSummaryVariants = [ "ines", "godot-pck", "unityfs", + "wxapkg", "uimage", "dmg", "pkg", diff --git a/src/domain/native/batchDecompile.ts b/src/domain/native/batchDecompile.ts index b1d78a9983..839f640ee2 100644 --- a/src/domain/native/batchDecompile.ts +++ b/src/domain/native/batchDecompile.ts @@ -38,6 +38,13 @@ export const batchDecompileItemSchema = z.discriminatedUnion("status", [ status: z.literal("ok"), pseudocode: z.string().min(1), }), + z.object({ + address: batchSelectorSchema, + procedure: batchProcedureIdentitySchema, + status: z.literal("unavailable"), + reason: z.literal("no_pseudocode"), + limitations: batchItemLimitationsSchema, + }), z.object({ address: batchSelectorSchema, procedure: batchProcedureIdentitySchema, diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts index 172c0c0e99..1d4e8a9410 100644 --- a/src/domain/native/binaryLayout.test.ts +++ b/src/domain/native/binaryLayout.test.ts @@ -122,6 +122,9 @@ it("retains high linked values and NOBITS without inventing file backing", () => }); it.each([ + [{ machine: "EM_AARCH64", bits: 64, byte_order: "little" }, true], + [{ machine: "EM_AARCH64", bits: 64, byte_order: "big" }, false], + [{ machine: "EM_AARCH64", bits: 32, byte_order: "little" }, false], [{ machine: "EM_386", bits: 32, byte_order: "little" }, true], [{ machine: "EM_ARM", bits: 32, byte_order: "little" }, true], [{ machine: "EM_MIPS", bits: 32, byte_order: "little" }, true], @@ -150,7 +153,7 @@ it.each([ if (problem === "file-backed-outside") section.file_backing = "file"; if (problem === "header-outside") section.header_location.offset = "0x80"; if (problem === "wrong-index") section.index = 1; - if (problem === "wrong-machine") value.architecture.machine = "EM_AARCH64"; + if (problem === "wrong-machine") value.architecture.machine = "EM_RISCV"; if (problem === "outside-elf64") value.entry_point.reported_value = "0x10000000000000000"; if (problem === "noncanonical-base64") section.name.bytes_base64 = "AR=="; diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts index c60a728d51..d80c9ca4ca 100644 --- a/src/domain/native/binaryLayout.ts +++ b/src/domain/native/binaryLayout.ts @@ -35,7 +35,7 @@ const binaryLayoutObjectSchema = z.strictObject({ format: z.literal("elf"), architecture: z.union([ z.strictObject({ - machine: z.literal("EM_X86_64"), + machine: z.enum(["EM_X86_64", "EM_AARCH64"]), bits: z.literal(64), byte_order: z.literal("little"), }), diff --git a/src/domain/native/bytePattern.ts b/src/domain/native/bytePattern.ts index 5f821493c9..20e99b4ea2 100644 --- a/src/domain/native/bytePattern.ts +++ b/src/domain/native/bytePattern.ts @@ -137,19 +137,24 @@ const coverageBlock = z }); }); -/** Inclusive page coverage; matches never span memory blocks. */ +/** Inclusive page coverage; match segment and file offset identify its start. */ export const byteSearchResultSchema = z .strictObject({ matches: z .array( z.strictObject({ address: z.string(), - segment: z.string(), + segment: z + .string() + .describe("Memory block containing the match start."), file_offset: z .int() .nonnegative() .max(Number.MAX_SAFE_INTEGER) - .nullable(), + .nullable() + .describe( + "Source file offset of the match start, not a guarantee of a contiguous file span.", + ), procedure: z .strictObject({ address: z.string(), name: z.string() }) .nullable(), diff --git a/src/domain/native/elfNotes.ts b/src/domain/native/elfNotes.ts index b6d60974de..915b009332 100644 --- a/src/domain/native/elfNotes.ts +++ b/src/domain/native/elfNotes.ts @@ -17,6 +17,10 @@ export const ELF_NOTE_TYPE_NAMES = [ "gnu_build_id", "gnu_gold_version", "gnu_property", + "core_prpsinfo", + "core_file", + "core_siginfo", + "core_auxv", "unknown", ] as const; export type ElfNoteTypeName = (typeof ELF_NOTE_TYPE_NAMES)[number]; @@ -64,6 +68,22 @@ export const gnuNoteTypeName = (type: number): ElfNoteTypeName => { } }; +/** Map CORE n_type when the owner is CORE; other types stay unknown. */ +export const coreNoteTypeName = (type: number): ElfNoteTypeName => { + switch (type) { + case 3: + return "core_prpsinfo"; + case 6: + return "core_auxv"; + case 0x46494c45: + return "core_file"; + case 0x53494749: + return "core_siginfo"; + default: + return "unknown"; + } +}; + /** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */ export const inspectElfNotesInputSchema = z.strictObject({ path: localPathStringSchema.describe( @@ -95,6 +115,51 @@ const gnuAbiTagSchema = z.strictObject({ subminor: uint, }); +const coreFileMappingSchema = z.strictObject({ + start_address: z.string().regex(/^0x[0-9a-f]+$/), + end_address: z.string().regex(/^0x[0-9a-f]+$/), + file_offset_pages: z.string().regex(/^0x[0-9a-f]+$/), + path: z.string(), + path_hex: z.string().regex(/^[0-9a-f]*$/), +}); + +const coreFileSchema = z.strictObject({ + count: uint, + page_size: uint, + mappings: z.array(coreFileMappingSchema), +}); + +const corePrpsinfoSchema = z.strictObject({ + state: uint, + pid: z.number().int(), + ppid: z.number().int(), + pgrp: z.number().int(), + sid: z.number().int(), + uid: uint, + gid: uint, + filename: z.string(), + psargs: z.string(), +}); + +const coreSiginfoSchema = z.strictObject({ + signo: z.number().int(), + code: z.number().int(), + errno: z.number().int(), +}); + +const coreAuxvEntrySchema = z.strictObject({ + type: uint, + type_name: z.string().nullable(), + value: z.string().regex(/^0x[0-9a-f]+$/), +}); + +export const coreNoteSchema = z.strictObject({ + prpsinfo: corePrpsinfoSchema.nullable(), + file: coreFileSchema.nullable(), + siginfo: coreSiginfoSchema.nullable(), + auxv: z.array(coreAuxvEntrySchema).nullable(), +}); + const noteEntrySchema = z.strictObject({ index: uint, table_kind: z.enum(["program", "section"]), @@ -125,6 +190,7 @@ const noteEntrySchema = z.strictObject({ gold_version: z.string().nullable(), }) .nullable(), + core: coreNoteSchema.nullable(), }); /** Observed ELF note tables; absence is an empty list, not proof notes never existed. */ diff --git a/src/domain/native/functionMatching.ts b/src/domain/native/functionMatching.ts index 14c341828f..6af90543f3 100644 --- a/src/domain/native/functionMatching.ts +++ b/src/domain/native/functionMatching.ts @@ -26,6 +26,13 @@ export const nativeFunctionMatchMemberSchema = z.strictObject({ evidence_links: z.array(evidenceId).min(1), limitations: z.array(z.string()), }); +export const nativeFunctionByteSourceSchema = z.strictObject({ + side: z.enum(["left", "right"]), + evidence_id: evidenceId, + limitations: z.array(z.string()), +}); +export const nativeFunctionByteSourceGuidance = + "A function's selected byte sources are its evidence_links listed in the function-matches byte-sources view. Inspect their limitations before interpreting the byte basis."; export const nativeFunctionMatchSchema = z.strictObject({ match_id: prefixedDigestSchema("match"), state: nativeMatchStateSchema, @@ -68,15 +75,21 @@ export const nativeFunctionMatchingContextSchema = z.strictObject({ right_structural_candidates: z.number().int().nonnegative(), }), summary: nativeFunctionMatchingSummarySchema, + byte_sources_count: z.number().int().nonnegative().optional(), }); export const nativeFunctionMatchingSchema = nativeFunctionMatchingContextSchema .extend({ matches: z.array(nativeFunctionMatchSchema), limitations: z.array(z.string()), + byte_sources: z.array(nativeFunctionByteSourceSchema).optional(), }) .superRefine((report, context) => { const seen = new Set(); const members = { left: new Set(), right: new Set() }; + const byteReferences = { + left: new Set(), + right: new Set(), + }; const fingerprints = { left: 0, right: 0 }; const candidates = { left: 0, right: 0 }; const counts = { matched: 0, only_left: 0, only_right: 0, unresolved: 0 }; @@ -119,6 +132,7 @@ export const nativeFunctionMatchingSchema = nativeFunctionMatchingContextSchema "Function occurrence identity, provenance or uniqueness disagrees with its selected inventory", }); members[side].add(member.function_id); + for (const id of member.evidence_links) byteReferences[side].add(id); if (member.body_sha256 !== null) fingerprints[side]++; if (member.structural_candidate_sha256 !== null) candidates[side]++; } @@ -170,6 +184,31 @@ export const nativeFunctionMatchingSchema = nativeFunctionMatchingContextSchema message: "Match state contradicts its occurrences", }); } + if ( + (report.byte_sources === undefined) !== + (report.byte_sources_count === undefined) || + (report.byte_sources !== undefined && + report.byte_sources_count !== report.byte_sources.length) + ) + context.addIssue({ + code: "custom", + message: "Byte-source count disagrees with recorded sources", + }); + if (report.byte_sources !== undefined) { + const recorded = { left: new Set(), right: new Set() }; + for (const source of report.byte_sources) { + if ( + recorded[source.side].has(source.evidence_id) || + !byteReferences[source.side].has(source.evidence_id) + ) + context.addIssue({ + code: "custom", + message: + "Byte source is duplicated or not used on its recorded side", + }); + recorded[source.side].add(source.evidence_id); + } + } if ( report.source_coverage.status !== (counts.only_left + counts.only_right + counts.unresolved > 0 diff --git a/src/domain/native/literalFeatureTrace.ts b/src/domain/native/literalFeatureTrace.ts new file mode 100644 index 0000000000..88988d90e7 --- /dev/null +++ b/src/domain/native/literalFeatureTrace.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { literalFeatureMatchSchema } from "./literalFeatureMatch.js"; +import { traceContainingProcedureSchema } from "./nativeObservation.js"; + +/** One observed reference with an independently resolved or unavailable owner. */ +export const literalFeatureReferenceSchema = z.object({ + target_address: z.string(), + source_address: z.string(), + containing_procedure: traceContainingProcedureSchema, +}); + +/** Complete result of a native literal feature trace. */ +export const literalFeatureTraceSchema = z.object({ + query: z.string(), + search_mode: z.literal("literal"), + matches: z.array(literalFeatureMatchSchema), + references: z.array(literalFeatureReferenceSchema), + truncated: z.boolean(), + residual_unknowns: z.array(z.string()), +}); + +export type LiteralFeatureReference = z.infer< + typeof literalFeatureReferenceSchema +>; diff --git a/src/domain/native/nativeDataType.ts b/src/domain/native/nativeDataType.ts index 4556df5772..2380ae0db1 100644 --- a/src/domain/native/nativeDataType.ts +++ b/src/domain/native/nativeDataType.ts @@ -12,7 +12,7 @@ export const nativeDataTypeInputSchema = z (value) => (value.type === undefined) !== (value.address === undefined), "Supply exactly one of type or address", ); -/** Types remain analysis database observations; child identities can be inspected separately. */ +/** Types and component identities remain analysis database observations. */ export const nativeDataTypeSchema = z.strictObject({ status: z.enum(["available", "unavailable"]), reason: z.string().nullable(), @@ -47,6 +47,21 @@ export const nativeDataTypeSchema = z.strictObject({ type_id: z.string(), bit_size: z.int().nonnegative().nullable(), bit_offset: z.int().nonnegative().nullable(), + bit_field: z + .strictObject({ + base_type_id: z.string(), + declared_bit_size: z.int().nonnegative(), + component_type_lookup: z.enum([ + "equivalent", + "different", + "not-found", + ]), + base_type_lookup: z.enum(["equivalent", "different", "not-found"]), + }) + .optional() + .describe( + "Observed bit-field base and exact manager lookups. bit_size is the effective width; bit_offset is the least-significant-bit offset within the base type. Omission means bit-field details were not reported.", + ), status: z.enum(["observed", "unsupported"]), flexible_tail: z.literal("unknown"), }), diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 13cb25c001..4975466635 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { objcSwiftMetadataSchema } from "./objcSwiftMetadata.js"; +import { + inspectNativeDispatchMetadata, + objcSwiftMetadataSchema, +} from "./objcSwiftMetadata.js"; import { nativeInvestigationGraphSchema, @@ -191,9 +194,18 @@ describe("native investigation graph traces", () => { }); describe("Interface Builder dispatch joins", () => { - it.each(["BuildViewController", "BuildView", "BuildButton", "BuildHandler"])( - "joins an action to its declared %s receiver class", - (className) => { + it.each([ + { + className: "BuildViewController", + recordOffset: 1944, + existingOffset: null, + }, + { className: "BuildView", recordOffset: 6040, existingOffset: null }, + { className: "BuildButton", recordOffset: null, existingOffset: null }, + { className: "BuildHandler", recordOffset: 1944, existingOffset: 1916 }, + ])( + "joins the declared $className without mixing record and implementation offsets", + ({ className, recordOffset, existingOffset }) => { const ui = buildInterfaceBuilderAnalysis({ targetSha256: "b".repeat(64), toolVersion: "test", @@ -221,38 +233,80 @@ describe("Interface Builder dispatch joins", () => { ], limits: interfaceBuilderLimitsSchema.parse({}), }); - const joined = joinInterfaceBuilderDispatch(ui.graph, { - objc_classes: [], - objc_protocols: [], - objc_categories: [], - swift_decls: [], - objc_ivars: [], - objc_protocol_records: [], + const recordLocation = { + address: recordOffset === null ? "0x1000" : "0x2000", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: recordOffset === null ? "symbol" : "binary_metadata", + description: "Explicit synthetic method-record location", + location: recordLocation, + artifact_path: "/synthetic/Fixture", + artifact_sha256: "b".repeat(64), + }, + ]; + const mappedEvidence = [ + { + ...recordEvidence[0], + kind: "reference", + description: "Explicit independently mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + }, + ]; + const inputGraph = + existingOffset === null + ? ui.graph + : nativeInvestigationGraphSchema.parse({ + ...ui.graph, + nodes: [ + ...ui.graph.nodes, + { + id: "native:function:0x1000", + kind: "function", + name: "Already mapped handler", + location: { address: "0x1000", file_offset: existingOffset }, + attributes: {}, + evidence: mappedEvidence, + }, + ], + }); + const metadata = objcSwiftMetadataSchema.parse({ + ...inspectNativeDispatchMetadata([], 1), objc_dispatch_implementations: [ { class_name: className, selector: "buildTapped:", method_type: "instance", implementation_address: "0x1000", - location: { address: "0x1000", file_offset: 0 }, + location: recordLocation, decode: { status: "decoded", reason: null }, - evidence, + evidence: recordEvidence, }, ], - swift_conformances: [], - swift_dispatch_slots: [], - swift_symbols: [], - relative_pointers: [], - coverage: [], - db_save_result: null, }); + const joined = joinInterfaceBuilderDispatch(inputGraph, metadata); expect(joined.edges).toContainEqual( expect.objectContaining({ relation: "objc_dispatch", resolution: "resolved", to: "native:function:0x1000", + evidence: expect.arrayContaining(recordEvidence), }), ); + const handler = joined.nodes.find( + ({ id }) => id === "native:function:0x1000", + ); + expect(handler?.location).toEqual({ + address: "0x1000", + file_offset: existingOffset, + }); + expect(handler?.evidence).toEqual( + existingOffset === null ? recordEvidence : mappedEvidence, + ); + expect(metadata.objc_dispatch_implementations[0]?.location).toEqual( + recordLocation, + ); }, ); }); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index b51ad6f63e..03c56bc705 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -391,7 +391,8 @@ export const joinInterfaceBuilderDispatch = ( name: `${implementation.class_name} ${selector}`, location: { address: implementation.implementation_address, - file_offset: implementation.location.file_offset, + // The implementation location identifies the method record, not its code. + file_offset: null, }, attributes: { class_name: implementation.class_name, diff --git a/src/domain/native/nativeObservation.ts b/src/domain/native/nativeObservation.ts index e09d51e31f..3844302437 100644 --- a/src/domain/native/nativeObservation.ts +++ b/src/domain/native/nativeObservation.ts @@ -66,6 +66,7 @@ export const NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS = [ export const NATIVE_OBSERVATION_OPERATIONS = [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const; export type NativeObservationOperation = (typeof NATIVE_OBSERVATION_OPERATIONS)[number]; @@ -74,6 +75,7 @@ export const nativeObservationSourceSchemas = { procedure_address: z.string(), procedure_callees: nativeAddressListSchema, procedure_callers: nativeAddressListSchema, + xrefs: nativeAddressListSchema, resolve_containing_procedure: containingProcedureResolutionSchema, get_call_graph: nativeCallGraphSchema, }; diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts index b0cff4409c..ab769cf200 100644 --- a/src/domain/nativeConvention.ts +++ b/src/domain/nativeConvention.ts @@ -22,6 +22,11 @@ const DESKTOP_IL2CPP_LIBRARIES = new Set([ "gameassembly.dylib", "libil2cpp.so", ]); +/** Unity's predefined script assembly names; asmdef assembly names are project-chosen. */ +const UNITY_PREDEFINED_ASSEMBLIES = new Set([ + "assembly-csharp.dll", + "assembly-csharp-firstpass.dll", +]); /** * A runtime family named by the library filename or a containing framework. @@ -48,11 +53,19 @@ export const isSwiftRuntimeLibrary = (path: string): boolean => .split("/") .some((segment) => /^libswift.+\.dylib$/u.test(foldSegment(segment))); -/** Parse desktop layout conventions once while preserving original path segments. */ -export const desktopPathConventions = (path: string) => { +/** Unity names the pre-IL2CPP managed backup `_BackUpThisFolder_ButDontShipItWithYourGame`. */ +const UNITY_IL2CPP_BACKUP_SUFFIX = + "_backupthisfolder_butdontshipitwithyourgame"; + +/** + * Parse desktop layout conventions once while preserving original path segments. + * `rootName` is the admitted root directory name; `.` denotes that root in returned directories. + */ +export const desktopPathConventions = (path: string, rootName?: string) => { const segments = path.split("/"); const folded = segments.map(foldSegment); let unityManagedDirectory: string | null = null; + let unityManagedIndex = -1; for (let index = 0; index + 2 < folded.length; index++) { const segment = folded[index]; const namedDataDirectory = @@ -64,12 +77,39 @@ export const desktopPathConventions = (path: string) => { if ( (namedDataDirectory || bundleDataDirectory) && folded[index + 1] === "managed" - ) + ) { unityManagedDirectory = segments.slice(0, index + 2).join("/"); + unityManagedIndex = index + 1; + } + } + // The admitted root may itself be the backup folder, so its name precedes the relative segments. + const rooted = + rootName === undefined ? folded : [foldSegment(rootName), ...folded]; + const shift = rooted.length - folded.length; + let unityIl2cppBackupDirectory: string | null = null; + let unityIl2cppBackupIndex = -1; + for (let index = 0; index + 1 < rooted.length; index++) { + const segment = rooted[index] ?? ""; + if ( + segment.length > UNITY_IL2CPP_BACKUP_SUFFIX.length && + segment.endsWith(UNITY_IL2CPP_BACKUP_SUFFIX) + ) { + unityIl2cppBackupDirectory = + index < shift ? "." : segments.slice(0, index - shift + 1).join("/"); + unityIl2cppBackupIndex = index - shift; + } } const name = folded.at(-1) ?? ""; return { + unityIl2cppBackupDirectory, + unityIl2cppBackupAssembly: + unityIl2cppBackupIndex === segments.length - 3 && + folded[unityIl2cppBackupIndex + 1] === "managed" && + UNITY_PREDEFINED_ASSEMBLIES.has(name), unityManagedDirectory, + unityPredefinedAssembly: + unityManagedIndex === segments.length - 2 && + UNITY_PREDEFINED_ASSEMBLIES.has(name), il2cppLibrary: DESKTOP_IL2CPP_LIBRARIES.has(name), electronArchive: folded.at(-2) === "resources" && diff --git a/src/domain/process/processCapture.comparison.test.ts b/src/domain/process/processCapture.comparison.test.ts index 7e22ea76a7..fac1b74530 100644 --- a/src/domain/process/processCapture.comparison.test.ts +++ b/src/domain/process/processCapture.comparison.test.ts @@ -726,3 +726,50 @@ it("refuses mixed terminal observation versions even with PID and port rules dis }), ); }); + +it("refuses mixed path normalization versions even with PID and port rules disabled", () => { + const base = emptyCapture(); + const scenario = processScenarioSchema.parse({ + executable: "/usr/bin/true", + normalization: { pids: false, ports: false, paths: true }, + }); + const committed = processScenarioCommitment( + scenario, + base.manifest.selected_executable_sha256 ?? undefined, + ); + const contract = processComparisonContract(scenario); + expect(contract.path_normalization_version).toBe( + "path-component-boundaries-v4", + ); + const capture = parseProcessCapture({ + ...base, + normalization: scenario.normalization, + manifest: { + ...base.manifest, + scenario: committed, + full_scenario_sha256: digestProcessCommitment(committed), + normalization_sha256: digestProcessCommitment(scenario.normalization), + comparison_contract: contract, + comparison_contract_sha256: digestProcessCommitment(contract), + }, + }); + const legacyContract = { + ...contract, + path_normalization_version: "path-component-boundaries-v3", + }; + const legacy = parseProcessCapture({ + ...capture, + manifest: { + ...capture.manifest, + comparison_contract: legacyContract, + comparison_contract_sha256: digestProcessCommitment(legacyContract), + }, + }); + expect(() => compareProcessCaptures(capture, legacy)).toThrow( + expect.objectContaining({ + issues: [ + expect.objectContaining({ expected: ["path_normalization_version"] }), + ], + }), + ); +}); diff --git a/src/domain/process/processScenario.test.ts b/src/domain/process/processScenario.test.ts index 2c2e1e5c80..73355d06c1 100644 --- a/src/domain/process/processScenario.test.ts +++ b/src/domain/process/processScenario.test.ts @@ -153,6 +153,22 @@ it("keeps scenario identity without finalization while versioning corrected port ).toBe("b50005d26269bd1db4a49c79e05bca67c3a18d837b45cd1c8ff591c01eb7623a"); const comparison = processComparisonContract(scenario); expect(comparison.port_normalization_version).toBe("contextual-endpoints-v3"); + expect(comparison).not.toHaveProperty("path_normalization_version"); + const withPaths = processComparisonContract( + processScenarioSchema.parse({ + ...scenario, + normalization: { ...scenario.normalization, paths: true }, + }), + ); + expect(withPaths.path_normalization_version).toBe( + "path-component-boundaries-v4", + ); + expect(digestProcessCommitment(withPaths)).not.toBe( + digestProcessCommitment({ + ...withPaths, + path_normalization_version: "path-component-boundaries-v3", + }), + ); const { pid_normalization_version: pidVersion, terminal_observation_version: terminalVersion, diff --git a/src/domain/process/processScenario.ts b/src/domain/process/processScenario.ts index 92a4d1b092..68c0bb17c9 100644 --- a/src/domain/process/processScenario.ts +++ b/src/domain/process/processScenario.ts @@ -162,7 +162,7 @@ export const processComparisonContract = ( ? { pid_normalization_version: "sampled-identifiers-v2" } : {}), ...(scenario.normalization.paths - ? { path_normalization_version: "path-component-boundaries-v3" } + ? { path_normalization_version: "path-component-boundaries-v4" } : {}), ...(scenario.normalization.ports ? { port_normalization_version: "contextual-endpoints-v3" } diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index 9ab1381548..ebdd1d30a0 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -152,8 +152,8 @@ it("preserves OpenStep dictionary and array autodetection", () => { }); it("preserves OpenStep __proto__ keys and duplicate assignment semantics", () => { - // Unquoted, quoted, escape-spelled, and object-valued keys all decode to - // __proto__; values, other keys, and `\137` (decoded as "137") do not. + // Unquoted, quoted, escape-spelled, and octal-spelled keys all decode to + // __proto__; last assignment wins. const result = parseXmlPropertyList( '{ __proto__ = a; "__proto__" = b; "\\_\\_proto__" = c; ' + 'nested = { "__proto__" = { polluted = yes; }; }; ' + @@ -161,11 +161,10 @@ it("preserves OpenStep __proto__ keys and duplicate assignment semantics", () => ); expect(result).toEqual({ value: { - ["__proto__"]: "c", + ["__proto__"]: "d", nested: { ["__proto__"]: { polluted: "yes" } }, kept: "__proto__", list: ["__proto__"], - "137_proto__": "d", }, }); const nested = (result.value as { nested: object }).nested; diff --git a/src/domain/propertyListOpenStep.test.ts b/src/domain/propertyListOpenStep.test.ts index 47d6538e59..32e13c73bf 100644 --- a/src/domain/propertyListOpenStep.test.ts +++ b/src/domain/propertyListOpenStep.test.ts @@ -10,7 +10,6 @@ import { it.each([ "{ numeric = (42, -2.5, 0x10, YES); path = /a/b:c$; }", - String.raw`{ escapes = "\n\r\t\0\"\\\q\137\U0041"; }`, "{ bytes = (, <0>, , <>); }", '/* header */ ("//text", "/*text*/", /* before */ value, // after\n)', "{ first = x second = y; third = (a, b,); }", @@ -169,3 +168,27 @@ it("does not treat nonterminating whitespace or quoted comment text as line ends ), ).toMatchObject({ code: "unexpected_end" }); }); + +it.each([ + ['{ CFBundleExecutable = "M\\U0061in"; }', { CFBundleExecutable: "Main" }], + ['{ CFBundleExecutable = "M\\141in"; }', { CFBundleExecutable: "Main" }], + ['{ "CFBundle\\U0045xecutable" = "Main"; }', { CFBundleExecutable: "Main" }], + ['{ accent = "\\U00e9"; }', { accent: "é" }], + ['{ nextstep = "\\335"; }', { nextstep: "é" }], + [ + String.raw`{ escapes = "\n\r\t\0\"\\\q\137\U0041"; }`, + { escapes: '\n\r\t\0"\\q_A' }, + ], + ['{ controls = "\\a\\b\\f\\v"; }', { controls: "\u0007\b\f\v" }], + ['{ surrogatePair = "\\Ud83d\\Ude00"; }', { surrogatePair: "😀" }], + ['{ loneHighSurrogate = "\\Ud800"; }', { loneHighSurrogate: "\ufffd" }], + ['{ loneLowSurrogate = "\\Udc00"; }', { loneLowSurrogate: "\ufffd" }], + ['{ octalBoundary376 = "\\376"; }', { octalBoundary376: "\ufffd" }], + ['{ octalBoundary377 = "\\377"; }', { octalBoundary377: "\ufffd" }], + ['{ octalWrap400 = "\\400"; }', { octalWrap400: "\0" }], +])( + "decodes OpenStep quoted escape sequences in keys and values for %s", + (text, expected) => { + expect(decodeOpenStepPropertyList(text)).toEqual(expected); + }, +); diff --git a/src/domain/propertyListOpenStep.ts b/src/domain/propertyListOpenStep.ts index cd54c6c51c..eabf0943ef 100644 --- a/src/domain/propertyListOpenStep.ts +++ b/src/domain/propertyListOpenStep.ts @@ -13,6 +13,159 @@ const OPEN_STEP_SYNTAX_CODES = [ "unexpected_character", ] as const; +/** + * NeXTSTEP character encoding mapping table for octal bytes 0x80 (128) to 0xFF (255) + * defined by Apple CoreFoundation CFOldStylePList / CFBuiltinConverters. + */ +const NEXT_STEP_LATIN_TABLE: readonly number[] = [ + 0x00a0, 0x00c0, 0x00c1, 0x00c2, 0x00c3, 0x00c4, 0x00c5, 0x00c7, 0x00c8, + 0x00c9, 0x00ca, 0x00cb, 0x00cc, 0x00cd, 0x00ce, 0x00cf, 0x00d0, 0x00d1, + 0x00d2, 0x00d3, 0x00d4, 0x00d5, 0x00d6, 0x00d9, 0x00da, 0x00db, 0x00dc, + 0x00dd, 0x00de, 0x00b5, 0x00d7, 0x00f7, 0x00a9, 0x00a1, 0x00a2, 0x00a3, + 0x2044, 0x00a5, 0x0192, 0x00a7, 0x00a4, 0x2019, 0x201c, 0x00ab, 0x2039, + 0x203a, 0xfb01, 0xfb02, 0x00ae, 0x2013, 0x2020, 0x2021, 0x00b7, 0x00a6, + 0x00b6, 0x2022, 0x201a, 0x201e, 0x201d, 0x00bb, 0x2026, 0x2030, 0x00ac, + 0x00bf, 0x00b9, 0x02cb, 0x00b4, 0x02c6, 0x02dc, 0x00af, 0x02d8, 0x02d9, + 0x00a8, 0x00b2, 0x02da, 0x00b8, 0x00b3, 0x02dd, 0x02db, 0x02c7, 0x2014, + 0x00b1, 0x00bc, 0x00bd, 0x00be, 0x00e0, 0x00e1, 0x00e2, 0x00e3, 0x00e4, + 0x00e5, 0x00e7, 0x00e8, 0x00e9, 0x00ea, 0x00eb, 0x00ec, 0x00c6, 0x00ed, + 0x00aa, 0x00ee, 0x00ef, 0x00f0, 0x00f1, 0x0141, 0x00d8, 0x0152, 0x00ba, + 0x00f2, 0x00f3, 0x00f4, 0x00f5, 0x00f6, 0x00e6, 0x00f9, 0x00fa, 0x00fb, + 0x0131, 0x00fc, 0x00fd, 0x0142, 0x00f8, 0x0153, 0x00df, 0x00fe, 0x00ff, + 0xfffd, 0xfffd, +]; + +/** + * Decode OpenStep quoted string escape sequences according to Apple CoreFoundation CFOldStylePList: + * supports \a, \b, \f, \n, \r, \t, \v, \", \\, \Uxxxx (4-hex unicode), and \ooo (1-3 octal digits in NeXTSTEP encoding). + */ +export const decodeOpenStepQuotedString = (token: string): string => { + // Validate token syntax with pinned parser to preserve parser error diagnosis. + parseOpenStep(token); + const content = token.slice(1, -1); + let result = ""; + let i = 0; + while (i < content.length) { + const ch = content[i]!; + if (ch !== "\\") { + result += ch; + i += 1; + continue; + } + i += 1; + if (i >= content.length) break; + const esc = content[i]!; + i += 1; + switch (esc) { + case "a": + result += "\u0007"; + break; + case "b": + result += "\b"; + break; + case "f": + result += "\f"; + break; + case "n": + result += "\n"; + break; + case "r": + result += "\r"; + break; + case "t": + result += "\t"; + break; + case "v": + result += "\v"; + break; + case '"': + result += '"'; + break; + case "\\": + result += "\\"; + break; + case "\n": + result += "\n"; + break; + case "U": { + let num = 0; + let digits = 0; + while (digits < 4 && i < content.length) { + const hexChar = content[i]!; + if (/^[0-9a-fA-F]$/.test(hexChar)) { + num = (num << 4) | parseInt(hexChar, 16); + i += 1; + digits += 1; + } else { + break; + } + } + if (num >= 0xd800 && num <= 0xdbff) { + if (content[i] === "\\" && content[i + 1] === "U") { + let lowNum = 0; + let lowDigits = 0; + let j = i + 2; + while (lowDigits < 4 && j < content.length) { + const hexChar = content[j]!; + if (/^[0-9a-fA-F]$/.test(hexChar)) { + lowNum = (lowNum << 4) | parseInt(hexChar, 16); + j += 1; + lowDigits += 1; + } else { + break; + } + } + if (lowDigits === 4 && lowNum >= 0xdc00 && lowNum <= 0xdfff) { + i = j; + result += String.fromCharCode(num, lowNum); + break; + } + } + result += "\ufffd"; + } else if (num >= 0xdc00 && num <= 0xdfff) { + result += "\ufffd"; + } else { + result += String.fromCharCode(num); + } + break; + } + case "0": + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": + case "7": { + let num = esc.charCodeAt(0) - 48; + for (let j = 0; j < 2; j += 1) { + if (i < content.length) { + const octChar = content[i]!; + if (octChar >= "0" && octChar <= "7") { + num = ((num << 3) + (octChar.charCodeAt(0) - 48)) & 0xff; + i += 1; + } else { + break; + } + } + } + if (num < 0x80) { + result += String.fromCharCode(num); + } else { + const codePoint = NEXT_STEP_LATIN_TABLE[num - 0x80]; + result += + codePoint !== undefined ? String.fromCharCode(codePoint) : "\ufffd"; + } + break; + } + default: + result += esc; + break; + } + } + return result; +}; + export type OpenStepPropertyListCode = (typeof OPEN_STEP_SYNTAX_CODES)[number]; /** OpenStep syntax refusal. `code` is the contract; `position` is the source offset. */ @@ -163,9 +316,10 @@ export const decodeOpenStepPropertyList = ( // Scalar dictionary keys do not add source-value nesting; compound keys do. if (!dictionaryKey) assertDepth(depth); const token = text.slice(start, cursor); - return character === '"' || character === "<" - ? parseOpenStep(token) - : token; + if (character === '"') { + return decodeOpenStepQuotedString(token); + } + return character === "<" ? parseOpenStep(token) : token; }; // The pinned format reads one root and leaves all trailing source untouched. return value(0); diff --git a/src/domain/reverseEngineering.ts b/src/domain/reverseEngineering.ts index f79e0ccafd..03601ae2c9 100644 --- a/src/domain/reverseEngineering.ts +++ b/src/domain/reverseEngineering.ts @@ -53,7 +53,7 @@ const objdumpDisassemblyInputSchema = objdumpArtifactInputSchema.extend({ "Symbol must be well-formed Unicode.", ) .describe( - "Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow GNU objdump; omitted means all code sections.", + "Native symbol to disassemble, expressed as well-formed Unicode. Matching and symbol boundaries follow the selected executable; omitted means all code sections.", ) .optional(), }); diff --git a/src/domain/webMcpDiscovery.ts b/src/domain/webMcpDiscovery.ts index a44dae347f..bbcbce5fa2 100644 --- a/src/domain/webMcpDiscovery.ts +++ b/src/domain/webMcpDiscovery.ts @@ -7,6 +7,7 @@ import { browserObservationEndpointSchema, } from "./browserObservation.js"; import { browserVersionSchema } from "./browserObservationSchemas.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; import { jsonValueSchema } from "./jsonValue.js"; import { digestSchema, prefixedDigestSchema } from "./../domain/digests.js"; @@ -44,7 +45,7 @@ const webMcpToolSchema = z.object({ .object({ url: z.string(), line: z.int().min(0).nullable(), - column: z.int().min(0).nullable(), + column: cdpResourceColumnSchema.nullable(), }) .nullable(), trust: z.literal("page-declared-untrusted"), diff --git a/src/domain/webRuntime.ts b/src/domain/webRuntime.ts index b707cb3a22..d565750215 100644 --- a/src/domain/webRuntime.ts +++ b/src/domain/webRuntime.ts @@ -9,6 +9,7 @@ import { webRuntimeCommandTimeoutSchema, } from "./browserDeadline.js"; import { jsonObjectSchema } from "./jsonValue.js"; +import { cdpResourceColumnSchema } from "./cdpObservationSchemas.js"; // CDP positions are zero-based and relative to the resource named by url. // With a script ID that is the script's enclosing resource: the script file, @@ -18,17 +19,18 @@ import { jsonObjectSchema } from "./jsonValue.js"; // association to join captured source or resource_start. // trace_web_source_location takes one-based, script-relative lines, so the // advertised units keep callers from passing a position off by one or by an -// inline script's offset. +// inline script's offset. Columns are kept as reported even when Chrome reports +// a negative one, so subtracting resource_start's column stays consistent. const SCRIPT_ID = "CDP session script ID; empty for a position a request initiator reported, which has no session-script association for joining captured source or resource_start."; const RESOURCE_LINE = "Zero-based line in the resource named by url, as reported by CDP: the script file for an external script (including a module-import initiator), or the HTML document for an inline script or parser markup."; const RESOURCE_COLUMN = - "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP."; + "Zero-based UTF-16 column on that line of the resource named by url, as reported by CDP. Chrome can report a negative column, for example on the first line of a script whose resource_start column is negative; it then does not locate a column in url."; const START_LINE = "Zero-based line where the script text starts in its enclosing resource (the HTML document for an inline script)."; const START_COLUMN = - "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource."; + "Zero-based UTF-16 column where the script text starts on that line of its enclosing resource. Chrome reports a negative column for some inline scripts; it is kept as reported and does not locate a column in that resource."; /** Scope for one externally owned page; omitted origins select its current origin. */ export const webRuntimeScopeSchema = z.strictObject({ @@ -53,7 +55,7 @@ export const webRuntimeSourceSchema = z.object({ resource_start: z .object({ line_number: z.int().min(0).describe(START_LINE), - column_number: z.int().min(0).describe(START_COLUMN), + column_number: cdpResourceColumnSchema.describe(START_COLUMN), }) .nullable(), source: z.discriminatedUnion("state", [ @@ -77,7 +79,7 @@ export const webRuntimeLocationSchema = z.object({ script_id: z.string().describe(SCRIPT_ID), url: z.string().nullable(), line_number: z.int().min(0).describe(RESOURCE_LINE), - column_number: z.int().min(0).nullable().describe(RESOURCE_COLUMN), + column_number: cdpResourceColumnSchema.nullable().describe(RESOURCE_COLUMN), function_name: z.string().nullable(), source_association: z.enum(["script_id", "unknown"]), }); diff --git a/src/domain/wxapkgFormat.ts b/src/domain/wxapkgFormat.ts new file mode 100644 index 0000000000..d1cecb0e39 --- /dev/null +++ b/src/domain/wxapkgFormat.ts @@ -0,0 +1,198 @@ +import { err, ok, type Result } from "./result.js"; + +/** + * WeChat mini-program package (wxapkg): an 18-byte big-endian header, a + * name/offset/size member table, then verbatim member data. Members are stored + * uncompressed at absolute file offsets. All multi-byte fields are big-endian. + */ +export const WXAPKG_FIRST_MARK = 0xbe; +export const WXAPKG_LAST_MARK = 0xed; +export const WXAPKG_HEADER_BYTES = 18; + +/** Minimum fixed row fields: name length, offset and size without name bytes. */ +export const WXAPKG_MIN_ROW_BYTES = 12; + +/** + * The member count is stored in the last four header bytes. Some producers + * include those bytes in `indexLength`; others count only the rows that follow. + */ +const WXAPKG_MEMBER_COUNT_BYTES = 4; + +/** + * Encrypted PC-client packages start with this ASCII magic instead of the + * plain header. Their member data is ciphertext; no decryption is supported. + */ +export const WXAPKG_ENCRYPTED_MAGIC = Buffer.from("V1MMWX", "latin1"); + +/** Whether bytes start with the encrypted PC-client magic. */ +export const hasWxapkgEncryptedMagic = (bytes: Uint8Array): boolean => + bytes.length >= WXAPKG_ENCRYPTED_MAGIC.length && + Buffer.from( + bytes.buffer, + bytes.byteOffset, + WXAPKG_ENCRYPTED_MAGIC.length, + ).equals(WXAPKG_ENCRYPTED_MAGIC); + +/** Declared header fields; support and extent admission belong to callers. */ +export interface WxapkgHeaderFacts { + /** Unknown header field after the first mark; producers write zero. */ + readonly info: number; + /** + * Declared index length. This is either the row bytes or those rows plus + * the 4-byte member count; {@link resolveWxapkgIndexLayouts} admits both. + */ + readonly indexLength: number; + /** Declared byte length of the member data region. */ + readonly dataLength: number; + readonly fileCount: number; +} + +/** + * Decode declared fields after the caller admits a prefix covering the fixed + * header. Undefined means the admitted bytes cannot carry a plausible header; + * both mark checks and minimal row accounting must hold. + */ +export const readWxapkgHeaderFacts = ( + bytes: Uint8Array, +): WxapkgHeaderFacts | undefined => { + if ( + bytes.length < WXAPKG_HEADER_BYTES || + bytes[0] !== WXAPKG_FIRST_MARK || + bytes[13] !== WXAPKG_LAST_MARK + ) + return undefined; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const info = view.getUint32(1); + const indexLength = view.getUint32(5); + const dataLength = view.getUint32(9); + const fileCount = view.getUint32(14); + // Every row carries at least its fixed fields; a table too small for the + // declared count cannot be a wxapkg index. + if (fileCount === 0 || indexLength < fileCount * WXAPKG_MIN_ROW_BYTES) + return undefined; + return { info, indexLength, dataLength, fileCount }; +}; + +/** + * Whether bytes carry the plain wxapkg marks. Recognition is intentionally + * separate from admission: a damaged package keeps its format identity and the + * reader rejects impossible table accounting as a typed format failure. + */ +export const hasWxapkgHeader = (bytes: Uint8Array): boolean => + bytes.length >= WXAPKG_HEADER_BYTES && + bytes[0] === WXAPKG_FIRST_MARK && + bytes[13] === WXAPKG_LAST_MARK; + +/** Row table and declared data region for one index-length convention. */ +export interface WxapkgIndexLayout { + /** Absolute offset of the first name/offset/size row. */ + readonly tableStart: number; + /** Row bytes to read. The member count, when included, stays in the header. */ + readonly tableBytes: number; + /** Inclusive start of the declared member-data region. */ + readonly dataStart: number; + /** Exclusive end of the declared member-data region. */ + readonly dataEnd: number; +} + +export type WxapkgIndexLayoutFailure = + | { readonly _tag: "IndexExceedsFile" } + | { readonly _tag: "DataRegionExceedsFile" }; + +const spanEnd = (start: number, length: number): number | undefined => { + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(length) || + length < 0 + ) + return undefined; + const end = start + length; + return Number.isSafeInteger(end) ? end : undefined; +}; + +const indexLayout = ( + facts: WxapkgHeaderFacts, + fileSize: number, + includedCountBytes: number, +): WxapkgIndexLayout | "index-outside" | "data-outside" | "too-small" => { + if ( + !Number.isSafeInteger(facts.indexLength) || + !Number.isSafeInteger(facts.fileCount) || + facts.fileCount < 0 || + facts.indexLength < includedCountBytes + ) + return "too-small"; + const tableBytes = facts.indexLength - includedCountBytes; + if (tableBytes < facts.fileCount * WXAPKG_MIN_ROW_BYTES) return "too-small"; + const dataStart = spanEnd(WXAPKG_HEADER_BYTES, tableBytes); + if (dataStart === undefined || dataStart > fileSize) return "index-outside"; + const dataEnd = spanEnd(dataStart, facts.dataLength); + if (dataEnd === undefined || dataEnd > fileSize) return "data-outside"; + return { tableStart: WXAPKG_HEADER_BYTES, tableBytes, dataStart, dataEnd }; +}; + +/** + * Admit every index-length convention whose index and declared data region + * both lie inside the carrier. Trailing bytes can make both fit, so callers + * keep every candidate until the parsed rows and member ranges decide. + */ +export const resolveWxapkgIndexLayouts = ( + facts: WxapkgHeaderFacts, + fileSize: number, +): Result => { + if (!Number.isSafeInteger(fileSize) || fileSize < 0) + return err({ _tag: "IndexExceedsFile" }); + const described = [WXAPKG_MEMBER_COUNT_BYTES, 0].map((included) => + indexLayout(facts, fileSize, included), + ); + const admissible = described.filter( + (layout): layout is WxapkgIndexLayout => typeof layout !== "string", + ); + if (admissible.length === 0) + return err({ + _tag: described.some((layout) => layout === "data-outside") + ? "DataRegionExceedsFile" + : "IndexExceedsFile", + }); + return ok(admissible); +}; + +const memberFitsIndex = ( + layout: WxapkgIndexLayout, + member: { readonly offset: number; readonly size: number }, +): boolean => { + const extentEnd = member.offset + member.size; + return ( + member.offset >= layout.dataStart && + extentEnd <= layout.dataEnd && + extentEnd >= member.offset + ); +}; + +/** + * Choose the admitted index that contains the parsed rows and every member + * range. An index that ends on the last parsed row wins a tie. Neither the + * carrier end nor the shorter table is enough on its own: four trailing bytes + * can make the other convention land on EOF. + */ +export const selectWxapkgIndexLayout = ( + layouts: readonly WxapkgIndexLayout[], + consumedRowBytes: number, + members: readonly { readonly offset: number; readonly size: number }[], +): WxapkgIndexLayout | undefined => { + const fitting = layouts.filter( + (layout) => + layout.tableBytes >= consumedRowBytes && + members.every((member) => memberFitsIndex(layout, member)), + ); + const exactRows = fitting.filter( + (layout) => layout.tableBytes === consumedRowBytes, + ); + const pool = exactRows.length > 0 ? exactRows : fitting; + let selected: WxapkgIndexLayout | undefined; + for (const layout of pool) { + if (selected === undefined || layout.tableBytes < selected.tableBytes) + selected = layout; + } + return selected; +}; diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index bbee47ca82..a68d45b004 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -1,6 +1,7 @@ import { ERLANG_BEAM_PROVIDER_IDENTITY } from "./ErlangBeamProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -154,13 +155,17 @@ const beamFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index 3ae76c1b21..bfa320f067 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -1,5 +1,6 @@ import type { EvmWorkerLimits } from "./EvmWorkerLimits.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, artifactSelectedPathErrno, @@ -218,6 +219,7 @@ export const evmInterfaceFailure = ( { cause, ...cleanupOptions, + ...stableArtifactInputRecovery(cause), }, [ { diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2a72fef70d..4dfa48ba8e 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -1,4 +1,5 @@ import { EvmAdmissionError } from "./EvmAdmissionError.js"; +import type { ProviderRejectionCode } from "../domain/providerSelection.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { safeParseUtf8Json } from "../domain/safeJson.js"; @@ -115,6 +116,7 @@ type EvmLimiterSelection = { readonly diagnostics: Readonly>; }; type EvmLimiterUnavailable = EvmLimiterSelection & { + readonly code: ProviderRejectionCode; readonly reason: string; readonly cause: unknown; }; @@ -159,16 +161,39 @@ const selectEvmLimiter = async ( if (cause instanceof EvmAdmissionError) return err({ path, + code: "not_configured", diagnostics: { ...diagnostics, code: cause.code, ...cause.detail, }, - reason: cause.code, + reason: `Set REA_EVM_PRLIMIT_COMMAND to an absolute executable regular file for caller-supplied util-linux prlimit. The selected path ${path} ${cause.code === "prlimit_not_absolute" ? "is not absolute" : "is not a regular file"}.`, cause, }); - const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}`; - return err({ path, diagnostics, reason, cause }); + const systemCode = + cause instanceof Error && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : undefined; + const permissionDenied = systemCode === "EACCES" || systemCode === "EPERM"; + const missing = systemCode === "ENOENT" || systemCode === "ENOTDIR"; + const recovery = permissionDenied + ? "Grant execute permission and filesystem access to the selected file, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit." + : missing + ? "Set REA_EVM_PRLIMIT_COMMAND to an existing absolute executable regular file for util-linux prlimit." + : "Check the selected file and filesystem access, or set REA_EVM_PRLIMIT_COMMAND to an accessible absolute executable regular file for util-linux prlimit."; + const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${path}; ${cause instanceof Error ? cause.message : String(cause)}. ${recovery}`; + return err({ + path, + code: missing ? "executable_missing" : "runtime_unavailable", + diagnostics: { + ...diagnostics, + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + reason, + cause, + }); } }; @@ -193,7 +218,7 @@ export const evmLimiterAvailability = async ( } : { status: "unavailable", - code: "executable_missing", + code: selected.error.code, reason: selected.error.reason, // The limiter owner does not yet author a separate repair (#8508). remediation: selected.error.reason, diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 8ab02027fe..b7cb294f14 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -10,10 +10,12 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; +import { firmwareSignalLimitError } from "./FirmwareFailures.js"; import { FirmwareFileAccess } from "./FirmwareFiles.js"; import { resolveFirmwareCommand, runFirmwareCommand, + type FirmwareLauncher, } from "./FirmwareCommand.js"; import { spawnOwnedProviderProcess, @@ -204,7 +206,11 @@ it("rejects a directory configured as the resource limiter executable", async () }); }); -it("keeps captured tool output when firmware stops on SIGXCPU", async () => { +// A launched producer that writes its output, then stops on a limit signal. +const signalledLauncher = ( + signal: NodeJS.Signals, + output: { stdout: string; stderr: string }, +): FirmwareLauncher => { const stdout = new PassThrough(); const stderr = new PassThrough(); const emitter = new EventEmitter(); @@ -216,20 +222,40 @@ it("keeps captured tool output when firmware stops on SIGXCPU", async () => { stdout, stderr, exitCode: null, - signalCode: "SIGXCPU", + signalCode: signal, kill: () => false, }; let ended = 0; const close = () => { ended += 1; - if (ended === 2) emitter.emit("close", null, "SIGXCPU"); + if (ended === 2) emitter.emit("close", null, signal); }; stdout.once("end", close); stderr.once("end", close); setTimeout(() => { - stdout.end("partial scan\n"); - stderr.end("cpu limit\n"); + stdout.end(output.stdout); + stderr.end(output.stderr); }, 15); + return () => + Promise.resolve({ + process: child, + ownership: { + runId: "rea-firmware-signal", + leaderPid: 4242, + processGroupId: 4242, + }, + cleanup: () => Promise.resolve({ cleaned: true, signaled: false }), + }); +}; + +const analysisDeadline = (signal: AbortSignal) => ({ + phase: "analysis" as const, + signal, + timeoutMs: 30_000, + expiresAtMs: performance.now() + 30_000, +}); + +it("keeps captured tool output when firmware stops on SIGXCPU", async () => { const analysis = new AbortController(); const failure: unknown = await runFirmwareCommand({ fileSizeCeiling: Number.MAX_SAFE_INTEGER, @@ -241,22 +267,11 @@ it("keeps captured tool output when firmware stops on SIGXCPU", async () => { cwd: await createTestTempDirectory("rea-firmware-signal-"), environment: process.env, signal: analysis.signal, - deadline: { - phase: "analysis", - signal: analysis.signal, - timeoutMs: 30_000, - expiresAtMs: performance.now() + 30_000, - }, - launcher: () => - Promise.resolve({ - process: child, - ownership: { - runId: "rea-firmware-signal", - leaderPid: 4242, - processGroupId: 4242, - }, - cleanup: () => Promise.resolve({ cleaned: true, signaled: false }), - }), + deadline: analysisDeadline(analysis.signal), + launcher: signalledLauncher("SIGXCPU", { + stdout: "partial scan\n", + stderr: "cpu limit\n", + }), retainCleanup: vi.fn(), }).catch((cause: unknown) => cause); expect(failure).toMatchObject({ @@ -279,6 +294,54 @@ it("keeps captured tool output when firmware stops on SIGXCPU", async () => { }); }); +const sixteenMiB = 16 * 1024 * 1024; +it.each([ + { + inherited: Number.MAX_SAFE_INTEGER, + limit: sixteenMiB + 1, + adjustable: true, + }, + { inherited: sixteenMiB + 1, limit: sixteenMiB + 1, adjustable: false }, +])( + "recommends a larger max_output_bytes on SIGXFSZ only when it raises the limit (inherited $inherited)", + async ({ inherited, limit, adjustable }) => { + const root = await createTestTempDirectory("rea-firmware-fsize-signal-"); + const analysis = new AbortController(); + const failure: unknown = await runFirmwareCommand({ + fileSizeCeiling: inherited, + engine: "unblob", + operation: "extract_firmware", + command: "/opt/unblob", + limiter: "/usr/bin/prlimit", + args: [], + cwd: root, + environment: process.env, + signal: analysis.signal, + deadline: analysisDeadline(analysis.signal), + launcher: signalledLauncher("SIGXFSZ", { stdout: "", stderr: "" }), + outputBudget: { root, bytes: sixteenMiB, entries: 10 }, + retainCleanup: vi.fn(), + }).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "file-size", + reportedLimits: { file_size_bytes: limit }, + retryable: false, + }); + // Compare with the recovery owner instead of its wording; the two branches + // must differ for the comparison to pin the selected one. + const recovery = (fileSizeAdjustable: boolean) => + firmwareSignalLimitError( + "extract_firmware", + "SIGXFSZ", + limit, + fileSizeAdjustable, + )?.remediationAction; + expect(recovery(true)).not.toBe(recovery(false)); + expect(failure).toHaveProperty("remediationAction", recovery(adjustable)); + }, +); + const executableFixture = async (root: string, name: string) => { const path = join(root, name); await writeFile(path, "#!/bin/sh\n"); @@ -290,11 +353,16 @@ test.each([ { selected: 1, inherited: Number.MAX_SAFE_INTEGER, - expected: 8 * 1024 * 1024, + expected: 8 * 1024 * 1024 + 1, }, { selected: 16 * 1024 * 1024, inherited: Number.MAX_SAFE_INTEGER, + expected: 16 * 1024 * 1024 + 1, + }, + { + selected: 16 * 1024 * 1024, + inherited: 16 * 1024 * 1024, expected: 16 * 1024 * 1024, }, { selected: 16 * 1024 * 1024, inherited: 1024, expected: 1024 }, diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 801cd8b97e..1ec49a9d42 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -153,6 +153,40 @@ export const resolveFirmwareCommand = async ( }; }; +/** + * The RLIMIT_FSIZE one producer run gets. It covers every producer file, + * including mandatory reports and logs; outputBudget inventory still governs + * aggregate payload. One byte above the largest budget makes a file the kernel + * stops at the limit exceed that budget instead of passing as complete. An + * inherited ceiling can remove that byte, so inventory also rejects files + * that reach the returned limit. + */ +export const firmwareFileSizeLimit = ( + fileSizeCeiling: number, + stagingBytes?: number, +): number => + Math.min( + fileSizeCeiling, + Math.max( + stagingBytes ?? 0, + FIRMWARE_LIMITS.reportBytes, + FIRMWARE_LIMITS.diagnosticBytes, + ) + 1, + ); + +/** + * Whether a larger max_output_bytes would raise the per-run file limit. It is + * false when the report and diagnostic allowances or the inherited ceiling, + * including its last byte of headroom, set that limit instead. + */ +const firmwareFileSizeAdjustable = ( + fileSizeCeiling: number, + stagingBytes?: number, +): boolean => + stagingBytes !== undefined && + firmwareFileSizeLimit(fileSizeCeiling, stagingBytes + 1) > + firmwareFileSizeLimit(fileSizeCeiling, stagingBytes); + /** Execute one bounded provider command and verify its owned descendants have stopped. */ export const runFirmwareCommand = async (context: { engine: string; @@ -172,21 +206,14 @@ export const runFirmwareCommand = async (context: { retainCleanup(supervisor: ProviderProcessSupervisor): void; }) => { const { engine, operation, signal } = context; - // RLIMIT_FSIZE covers every producer file, including mandatory reports/logs. - // Aggregate extracted payload remains governed by outputBudget inventory. - const fileSizeBytes = Math.min( + const fileSizeBytes = firmwareFileSizeLimit( context.fileSizeCeiling, - Math.max( - context.outputBudget?.bytes ?? 0, - FIRMWARE_LIMITS.reportBytes, - FIRMWARE_LIMITS.diagnosticBytes, - ), + context.outputBudget?.bytes, + ); + const fileSizeAdjustable = firmwareFileSizeAdjustable( + context.fileSizeCeiling, + context.outputBudget?.bytes, ); - const fileSizeAdjustable = - context.outputBudget !== undefined && - context.fileSizeCeiling > context.outputBudget.bytes && - context.outputBudget.bytes >= - Math.max(FIRMWARE_LIMITS.reportBytes, FIRMWARE_LIMITS.diagnosticBytes); const checkInterruption = () => { const failure = firmwarePhaseInterruption( operation, @@ -325,6 +352,7 @@ export const runFirmwareCommand = async (context: { context.outputBudget.root, context.outputBudget.bytes, context.outputBudget.entries, + fileSizeBytes, operation, signal, ); diff --git a/src/firmware/FirmwareFailures.ts b/src/firmware/FirmwareFailures.ts index f9066251ea..ea79d7a254 100644 --- a/src/firmware/FirmwareFailures.ts +++ b/src/firmware/FirmwareFailures.ts @@ -65,7 +65,7 @@ export const firmwareSignalLimitError = ( if (signal !== "SIGXFSZ") return undefined; return recovery( fileSizeAdjustable - ? `The file-size cap for this extraction is max_output_bytes at ${String(fileSizeBytes)} bytes. analysis_timeout_ms does not raise it. Increase max_output_bytes when it is below its schema maximum and the larger staging budget is still required. Repeating the unchanged request fails the same way.` + ? `The file-size cap for this extraction is ${String(fileSizeBytes)} bytes, one byte above max_output_bytes. analysis_timeout_ms does not raise it. Increase max_output_bytes when it is below its schema maximum and the larger staging budget is still required. Repeating the unchanged request fails the same way.` : firmwareLimitRecovery( `The firmware file-size allowance is ${String(fileSizeBytes)} bytes`, ), @@ -79,7 +79,7 @@ export const firmwareProcessLimitRecovery = ( ): string => `cpuSeconds is ${String(FIRMWARE_LIMITS.cpuSeconds)}. The address-space allowance is ${String(FIRMWARE_LIMITS.addressSpaceBytes)} bytes. This release has no runtime override for those per-process allowances, and analysis_timeout_ms does not raise them. ${ fileSizeAdjustable - ? `The file-size cap for this call is max_output_bytes at ${String(fileSizeBytes)} bytes. analysis_timeout_ms does not raise it.` + ? `The file-size cap for this call is ${String(fileSizeBytes)} bytes, one byte above max_output_bytes. analysis_timeout_ms does not raise it.` : `The file-size allowance is ${String(fileSizeBytes)} bytes. This release has no runtime override for that allowance, and analysis_timeout_ms does not raise it.` }`; diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index e0eeeb7532..612062b08d 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -356,11 +356,15 @@ const firmwareInputFailure = (operation: string, cause?: unknown) => { ]); }; -/** Inventory private output without following links, enforcing aggregate staging budgets. */ +/** + * Inventory private output without following links, enforcing aggregate staging + * budgets and the producer's per-file limit. + */ export const inventoryFirmwareOutput = async ( root: string, maxBytes: number, maxEntries: number, + fileSizeBytes: number, operation: string, signal?: AbortSignal, ): Promise => { @@ -430,6 +434,8 @@ export const inventoryFirmwareOutput = async ( if (stat.isFile()) bytes += stat.size; if (bytes > maxBytes) throw firmwareStagingLimit(operation, "max_output_bytes", maxBytes); + if (stat.isFile() && stat.size >= fileSizeBytes) + throw firmwareFileSizeLimitReached(operation, fileSizeBytes); const linkTarget = stat.isSymbolicLink() ? observedSymlinkTarget(await readlink(path, { encoding: "buffer" })) : undefined; @@ -490,6 +496,26 @@ const firmwareStagingLimit = ( }, ); +/** + * The kernel stops a write at RLIMIT_FSIZE without extending the file, so a + * staged file that reaches the producer's limit may be truncated. That limit + * sits one byte above every budget unless an inherited ceiling removed the byte. + */ +export const firmwareFileSizeLimitReached = ( + operation: string, + fileSizeBytes: number, +) => + new AnalysisResourceConstraintError( + operation, + "file-size", + `A staged firmware file reached the ${String(fileSizeBytes)}-byte per-file limit and may be truncated`, + { file_size_bytes: fileSizeBytes }, + { + retryable: false, + remediationAction: `REA inherited a ${String(fileSizeBytes)}-byte file-size limit (RLIMIT_FSIZE), so it could not keep one byte of headroom above max_output_bytes and the report and diagnostic allowances. A staged file that reaches the limit cannot be told apart from one the kernel cut short. Raise the file-size limit REA inherits (for example ulimit -f in the launching shell or service) above the largest file the extraction needs. analysis_timeout_ms does not raise it, and repeating the unchanged request fails the same way.`, + }, + ); + /** Bind producer paths to the selected input or private extraction tree. */ export const firmwareLogicalPath = ( path: string, diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 377095073d..4941cb2049 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -1,3 +1,4 @@ +import { FIRMWARE_OPERATION_ENGINES } from "../contracts/firmware/firmwareToolRequirements.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { assertArtifactCommandPath } from "../process/ArtifactCommandPath.js"; import { join } from "node:path"; @@ -38,6 +39,7 @@ import { } from "./FirmwareDeadline.js"; import { admitFirmwareVersion } from "./FirmwareVersion.js"; import { + firmwareFileSizeLimit, firmwareToolUnavailable, resolveFirmwareCommand, runFirmwareCommand, @@ -332,8 +334,7 @@ export class FirmwareProvider implements FirmwareAnalysisPort { const environment = snapshotEnvironment(this.environment); let root: PrivateRuntimeRoot | undefined; let rootLease: AnalysisCleanupLease | undefined; - const engineName = - request.operation === "inspect_firmware_regions" ? "binwalk" : "unblob"; + const engineName = FIRMWARE_OPERATION_ENGINES[request.operation]; const withCaller = (phase: FirmwareDeadline) => options?.signal === undefined ? phase.signal @@ -522,6 +523,10 @@ export class FirmwareProvider implements FirmwareAnalysisPort { outputRoot, request.input.max_output_bytes, request.input.max_output_files, + firmwareFileSizeLimit( + command.fileSizeCeiling, + request.input.max_output_bytes, + ), request.operation, signal, ); diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts index 848f9bec51..26ba90e132 100644 --- a/src/firmware/devicetree/DevicetreeProvider.ts +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -153,13 +154,17 @@ const devicetreeFailurePrimary = ( } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index 8cf8cbc056..6af0a5a35a 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -1023,7 +1023,7 @@ export class FlutterBuildProvider { limitations: [ "The APK is read through an owned read-only snapshot; concurrent changes to the original cannot mix into this observation", "The snapshot hash identifies a Dart SDK build only against an external mapping dataset; REA performs no lookup", - "Release engine builds often carry no labeled Dart version string; absence is reported, not guessed", + "Unlabeled Dart version values are identified by their printable-string shape, not a symbol or other source identity; a matching unrelated string may be reported and a null value means no supported pattern was found", "Engine build IDs come only from ELF-declared PT_NOTE/SHT_NOTE records; conflicting or incompletely examined notes preserve candidates and report partial coverage", ...[...facts].flatMap(([abi, value]) => [ ...value.libflutter.buildIdFailures.map( diff --git a/src/flutter/FlutterPayloadScan.test.ts b/src/flutter/FlutterPayloadScan.test.ts index 4e36d78efa..85ba203f41 100644 --- a/src/flutter/FlutterPayloadScan.test.ts +++ b/src/flutter/FlutterPayloadScan.test.ts @@ -124,34 +124,26 @@ it("distinguishes a full candidate set from omitted conflicts", () => { expect(scanner.result().candidates).toHaveLength(64); }); -describe("LabeledStringScanner", () => { - const assertSingleToolchainLine = (line: string): void => { - const scanner = new LabeledStringScanner(); - scanner.push( - Uint8Array.from( - Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), - ), - ); - expect(scanner.result().toolchainLines).toEqual([line]); - }; +const assertSingleToolchainLine = (line: string): void => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from(`\x00${line}\x00`, (character) => character.charCodeAt(0)), + ), + ); + expect(scanner.result().toolchainLines).toEqual([line]); +}; + +describe("LabeledStringScanner toolchain lines", () => { it("collects a full clang line within the 500-character bound", () => { assertSingleToolchainLine( "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc1a2abf3ef8d4301736d6a7ebc224a290) (based on LLVM 9.0.8svn)", ); }); - it("reads a labeled Dart VM version string when a build carries one", () => { - const scanner = new LabeledStringScanner(); - scanner.push( - Uint8Array.from( - Array.from( - "\x00Dart VM version: 3.1.0 (stable) somewhere\x00", - (character) => character.charCodeAt(0), - ), - ), - ); - expect(scanner.result().dartVersion).toBe( - "Dart VM version: 3.1.0 (stable) somewhere", + it("collects the labeled Android clang line", () => { + assertSingleToolchainLine( + "Android (5900059 based on r365631c) clang version 9.0.8 (https://android.googlesource.com/toolchain/llvm-project 207d7abc) (based on LLVM 9.0.8svn)", ); }); @@ -170,6 +162,67 @@ describe("LabeledStringScanner", () => { expect(result.toolchainLines).toEqual([]); }); + it("preserves trailing whitespace on LLVM toolchain lines", () => { + assertSingleToolchainLine("based on LLVM 9 "); + }); +}); + +describe("LabeledStringScanner Dart version strings", () => { + it("reads a labeled Dart VM version string when a build carries one", () => { + const scanner = new LabeledStringScanner(); + scanner.push( + Uint8Array.from( + Array.from( + "\x00Dart VM version: 3.1.0 (stable) somewhere\x00", + (character) => character.charCodeAt(0), + ), + ), + ); + expect(scanner.result().dartVersion).toBe( + "Dart VM version: 3.1.0 (stable) somewhere", + ); + }); + + it.each([ + [ + "release engine", + '3.7.2 (stable) (Tue Mar 11 04:27:50 2025 -0700) on "android_arm64"', + ], + [ + "Flutter Gallery engine", + "2.14.0-151.0.dev (dev) (Tue May 25 10:58:39 2021 -0700)", + ], + ] as const)( + "reads an unlabeled Dart Version::String() from a %s", + (_label, line) => { + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from(`\x00${line}\x00`)); + expect(scanner.result()).toMatchObject({ + dartVersion: line, + dartVersionCapped: false, + }); + }, + ); + + it("ignores unlabeled version-shaped text without a channel token", () => { + const scanner = new LabeledStringScanner(); + scanner.push( + Buffer.from( + '1.2.3 (Tue May 25 10:58:39 2021 -0700) on "android_arm64"\x00', + ), + ); + expect(scanner.result().dartVersion).toBeNull(); + }); + + it("does not claim a Dart cap for unlabeled runs that miss the grammar", () => { + const scanner = new LabeledStringScanner(); + scanner.push(Buffer.from("3.7.2 (stable)\x00")); + expect(scanner.result()).toMatchObject({ + dartVersion: null, + dartVersionCapped: false, + }); + }); + it("claims the cap only for an overlong run shaped like a toolchain line", () => { const overlongToolchain = `Android (${"r".repeat(700)}) clang version 9`; for (const [text, toolchainCapped, dartVersionCapped] of [ @@ -194,6 +247,13 @@ describe("LabeledStringScanner", () => { it.each([ { label: "Dart", line: `Dart VM version:${"x".repeat(200)}`, dart: true }, + { + label: "embedded Dart", + line: '3.7.2 (stable) (Tue Mar 11 04:27:50 2025 -0700) on "android_arm64"', + dart: true, + // Trailing junk breaks the whole-run grammar; do not treat as a cap. + invalidCaps: false, + }, { label: "Android clang", line: `Android (${"r".repeat(80)}) clang version ${"x".repeat(500)}`, @@ -202,7 +262,7 @@ describe("LabeledStringScanner", () => { { label: "LLVM", line: `based on LLVM 9${"x".repeat(80)}`, dart: false }, ])( "requires the complete $label run at its own grammar boundary", - ({ line, dart }) => { + ({ line, dart, invalidCaps }) => { for (const suffix of ["", "x", "x".repeat(1000)]) { const payload = Buffer.from(`${line}${suffix}\x00`); for (const chunkSize of [1, 7, payload.length]) { @@ -210,9 +270,10 @@ describe("LabeledStringScanner", () => { for (let offset = 0; offset < payload.length; offset += chunkSize) scanner.push(payload.subarray(offset, offset + chunkSize)); const valid = suffix === ""; + const capsInvalid = invalidCaps ?? true; expect(scanner.result()).toEqual({ dartVersion: dart && valid ? line : null, - dartVersionCapped: dart && !valid, + dartVersionCapped: dart && !valid && capsInvalid, toolchainLines: !dart && valid ? [line] : [], toolchainCapped: !dart && !valid, }); @@ -221,8 +282,7 @@ describe("LabeledStringScanner", () => { }, ); - it("preserves whitespace and separates distinct Dart-label omissions", () => { - assertSingleToolchainLine("based on LLVM 9 "); + it("separates distinct Dart-label omissions", () => { const scanner = new LabeledStringScanner(); scanner.push(Buffer.from("Dart VM version: 3.1\0Dart VM version: 3.2\0")); expect(scanner.result()).toMatchObject({ diff --git a/src/flutter/FlutterPayloadScan.ts b/src/flutter/FlutterPayloadScan.ts index a58ee5cc47..733f3e184f 100644 --- a/src/flutter/FlutterPayloadScan.ts +++ b/src/flutter/FlutterPayloadScan.ts @@ -5,8 +5,8 @@ import { Buffer } from "node:buffer"; * * All facts are observations of byte patterns at read time: the Dart * snapshot hash sits 20 bytes after each snapshot magic, the GNU build-id - * is an ELF note, and version facts are labeled ASCII strings. Nothing is - * inferred or looked up externally. + * is an ELF note, and version facts are ASCII strings. Nothing is inferred or + * looked up externally. */ /** First index of `needle` in `haystack` at or after `from`, or -1. */ @@ -124,6 +124,13 @@ export interface LabeledStringScan { } const DART_VERSION_PATTERN = /^Dart VM version:[^\x00\n]{0,200}$/u; +/** + * Unlabeled Dart `Version::String()` form embedded in release engines: + * ` () ()` with optional ` on "_"`. + * Requires a channel token so ordinary dotted version text is ignored. + */ +const DART_EMBEDDED_VERSION_PATTERN = + /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)? \((?:stable|beta|dev|main)\) \([^)\x00\n]{1,80}\)(?: on "[a-z0-9._-]+")?$/u; const TOOLCHAIN_PATTERN = /^(?:Android \([^)\x00\n]{0,80}\) clang version [^\x00\n]{0,500}|based on LLVM \d[^\x00\n]{0,80})$/u; const MAX_TOOLCHAIN_LINES = 4; @@ -135,11 +142,11 @@ const MAX_TOOLCHAIN_LINES = 4; const CARRY_BYTES = 640; /** - * Incremental ASCII string collector that keeps only labeled version and - * toolchain facts. A match is committed only when a printable run is - * terminated by a delimiter, so a value split across chunk boundaries is - * never committed truncated; the carry holds the longest accepted match - * so boundaries in either pattern survive. + * Incremental ASCII string collector that keeps Dart version and toolchain + * facts. A match is committed only when a printable run is terminated by a + * delimiter, so a value split across chunk boundaries is never committed + * truncated; the carry holds the longest accepted match so boundaries in + * either pattern survive. */ export class LabeledStringScanner { #dartVersion: string | null = null; @@ -173,15 +180,28 @@ export class LabeledStringScanner { this.overflowed = false; } + #recordDartVersion(text: string, accepted: boolean): void { + if (!accepted) { + this.#dartVersionCapped = true; + return; + } + if (this.#dartVersion === null) this.#dartVersion = text; + else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + } + #consider(text: string): void { // Keep a bounded prefix plus overflow state, never a sliding suffix. // Every accepted label must consume its whole printable run so a // pattern's shorter grammar cannot publish a fabricated prefix. if (text.startsWith("Dart VM version:")) { - if (this.overflowed || !DART_VERSION_PATTERN.test(text)) - this.#dartVersionCapped = true; - else if (this.#dartVersion === null) this.#dartVersion = text; - else if (this.#dartVersion !== text) this.#dartVersionCapped = true; + this.#recordDartVersion( + text, + !this.overflowed && DART_VERSION_PATTERN.test(text), + ); + return; + } + if (DART_EMBEDDED_VERSION_PATTERN.test(text)) { + this.#recordDartVersion(text, !this.overflowed); return; } if (!text.startsWith("Android (") && !text.startsWith("based on LLVM ")) diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index cf9047bc6d..23b2372e75 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -520,6 +520,7 @@ interface GdbSession { bufferTail: Buffer | undefined; bufferTailBytes: number; bufferBytes: number; + discardingOversizedStreamRecord: boolean; invalidUtf8Observed: boolean; promptTail: string; ready: boolean; @@ -881,6 +882,7 @@ export class GdbSessionManager { bufferTail: undefined, bufferTailBytes: 0, bufferBytes: 0, + discardingOversizedStreamRecord: false, invalidUtf8Observed: false, promptTail: "", ready: false, @@ -1663,6 +1665,13 @@ export class GdbSessionManager { []; let offset = 0; while (offset < data.byteLength) { + if (session.discardingOversizedStreamRecord) { + const newline = data.indexOf(0x0a, offset); + if (newline < 0) break; + session.discardingOversizedStreamRecord = false; + offset = newline + 1; + continue; + } const newline = data.indexOf(0x0a, offset); const piece = data.subarray( offset, @@ -1672,6 +1681,25 @@ export class GdbSessionManager { const lineBytes = session.bufferBytes - (newline >= 0 && piece.at(-1) === 0x0d ? 1 : 0); if (lineBytes > MAX_MI_LINE_BYTES) { + const firstByte = + session.bufferChunks[0]?.at(0) ?? + session.bufferTail?.at(0) ?? + piece.at(0); + if (firstByte === 0x7e || firstByte === 0x40 || firstByte === 0x26) { + session.bufferChunks.length = 0; + session.bufferTail = undefined; + session.bufferTailBytes = 0; + session.bufferBytes = 0; + for (const pending of session.pending.values()) + pending.outputTruncated = true; + session.recentRecordsTruncated = true; + if (newline < 0) { + session.discardingOversizedStreamRecord = true; + break; + } + offset = newline + 1; + continue; + } this.#failProtocol( session, `GDB emitted an MI line exceeding the ${String(MAX_MI_LINE_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`, @@ -2164,6 +2192,8 @@ export class GdbSessionManager { ); }); }, this.#commandTimeoutMs); + // Cleanup bounds its own wait, so a late reply must not hold the host open. + if (session.cleanupStarted) timer.unref(); pending = { token, command, diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 2d67071390..ce2709114c 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -6,5 +6,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "59", + skillVersion: "60", } as const; diff --git a/src/ghidra/GhidraAdmissionError.ts b/src/ghidra/GhidraAdmissionError.ts index f6ecbf7890..428c0170fa 100644 --- a/src/ghidra/GhidraAdmissionError.ts +++ b/src/ghidra/GhidraAdmissionError.ts @@ -28,6 +28,8 @@ const GHIDRA_ADMISSION_CODES = [ "prepared_options_exceed_bound", "output_unobserved", "handshake_identity", + "import_identity_unavailable", + "import_identity_mismatch", "response_shape", "response_json", "seed_path_differs", diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 3e3802f846..95eab2971a 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -55,19 +55,21 @@ import { /** Shared analysis commitments for raw and header-selected imports. */ const sharedAnalysisSemantics = { annotation_policy: "atomic-function-entry-metadata-v1", + byte_search_boundary: "contiguous-eligible-initialized-blocks-v1", load_image_observations: "source-mappings-entry-context-relocation-symbol-v3", function_body_evidence: "complete-inclusive-ranges-v1", - function_references: "complete-body-and-entry-reference-manager-v2", + function_references: "complete-body-and-entry-reference-manager-v3", call_relationship_coverage: "function-body-call-coverage-v5", function_dossier_coverage: "observed-facets-and-call-relationships-v1", string_inventory_evidence: "defined-data-coverage-v1", + data_type_observations: "bit-field-base-and-lookup-v1", location_resolution: "explicit-address-exact-entry-symbol-first-v3", procedure_search: "primary-and-exact-entry-symbol-names-v1", procedure_file_offset_resolution: "address-first-unique-target-source-mapping-v2", instruction_flow_evidence: "decoded-return-pcode-v1", instruction_status: "unlisted-address-without-decode-v1", - instruction_references: "exact-address-incoming-without-synthetic-entry-v2", + instruction_references: "exact-address-incoming-without-synthetic-entry-v3", function_boundary_observations: "ghidra-function-boundary-limitations-v4", } as const; @@ -474,11 +476,13 @@ export const existingGhidraAnalysisProfile = ( target: BinaryTarget, provider: Parameters[0], project: JsonValue, -) => - createAnalysisProfile(provider, { +) => { + const mipsElf = ghidraMipsProfileParameters(target).mips_elf; + return createAnalysisProfile(provider, { target_kind: target.kind, target_format: target.format, architecture: target.architecture ?? null, + ...(mipsElf === undefined ? {} : { mips_elf: mipsElf }), import_mode: "existing-project-private-copy-v1", ...sharedAnalysisSemantics, annotation_policy: "read-only-existing-project-v1", @@ -487,3 +491,4 @@ export const existingGhidraAnalysisProfile = ( no_return_repair: "disabled-existing-project", process_launch: "inspected-jvm-launch-support-v1", }); +}; diff --git a/src/ghidra/GhidraClientStartup.ts b/src/ghidra/GhidraClientStartup.ts index 092e2d7840..bab00101eb 100644 --- a/src/ghidra/GhidraClientStartup.ts +++ b/src/ghidra/GhidraClientStartup.ts @@ -8,6 +8,7 @@ import { type GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; /** Inputs needed to complete the post-connection bridge handshake. */ export interface GhidraStartupHandshakeOptions { @@ -42,6 +43,25 @@ export async function completeGhidraStartupHandshake( options.deadline.interruption !== undefined ? interruptionFailure(options) : ping.error; + if ( + failure.kind === "remote" && + (failure.remoteCode === "import_identity_unavailable" || + failure.remoteCode === "import_identity_mismatch") + ) { + const admitted = options.failure( + "admission", + failure.message, + new GhidraAdmissionError(failure.remoteCode, undefined, { + cause: failure, + }), + { remoteCode: failure.remoteCode }, + ); + return err( + failure.cleanupFailure === undefined + ? admitted + : admitted.withCleanupFailure(failure.cleanupFailure), + ); + } return err(failure); } const parsed = options.parseSessionInfo(ping.value); diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts index f830572f61..3e0daeffc0 100644 --- a/src/ghidra/GhidraInstallation.test.ts +++ b/src/ghidra/GhidraInstallation.test.ts @@ -27,6 +27,7 @@ import { ghidraJavaEnvironment, discoverGhidraInstallationOnPath, inspectGhidraInstallation, + systemGhidraInstallationHost, type GhidraInstallationHost, } from "./GhidraInstallation.js"; import { @@ -1732,6 +1733,212 @@ describe("Ghidra Java probe os.arch extraction", () => { }); }); +it("enumerates installed opt aliases without requiring a Homebrew executable", async () => { + const root = await createTestTempDirectory("rea-ghidra-opt-aliases-"); + try { + await mkdir(join(root, "openjdk@21")); + await mkdir(join(root, "unrelated")); + const systemHost = systemGhidraInstallationHost(); + expect(systemHost.directoryEntries?.(root).toSorted()).toEqual([ + "openjdk@21", + "unrelated", + ]); + expect(systemHost.directoryEntries?.(join(root, "absent"))).toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["arm64", "x64"] as const)( + "discovers installed versioned Homebrew Java on macOS %s", + async (architecture) => { + const prefix = architecture === "arm64" ? "/opt/homebrew" : "/usr/local"; + const command = `${prefix}/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java`; + const commands: string[] = []; + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: "/usr/bin" }, + installDir: INSTALL, + platform: "darwin", + architecture, + }, + host({ + directoryEntries: (directory) => + directory === `${prefix}/opt` + ? ["openjdk@21", "unrelated", "openjdk@21/../foreign"] + : [], + executable: (candidate) => + candidate === "/usr/bin/java" || + candidate === command || + candidate.endsWith("/mac_x86_64/decompile") || + host().executable(candidate), + probeJava: (candidate) => { + commands.push(candidate); + return candidate === command + ? javaProbeOk({ + ...JAVA, + home: command.slice(0, -9), + osName: "Mac OS X", + osArch: architecture === "arm64" ? "aarch64" : "x86_64", + }) + : missingJavaProbe; + }, + }), + ); + expect(commands).toEqual(["/usr/bin/java", command]); + expect(inspection.status).toBe("available"); + expect(inspection.javaCommand).toBe(command); + }, +); + +it.each([ + ["arm64", true, "/opt/homebrew", "/usr/local"], + ["x64", true, "/usr/local", "/opt/homebrew"], + ["arm64", false, "/opt/homebrew", "/usr/local"], + ["x64", false, "/usr/local", "/opt/homebrew"], +] as const)( + "orders installed Homebrew Java candidates on macOS %s with PATH Java %s", + async (architecture, pathJava, preferred, other) => { + const suffix = "/libexec/openjdk.jdk/Contents/Home/bin/java"; + const candidates = [ + `${preferred}/opt/openjdk${suffix}`, + `${preferred}/opt/openjdk@21${suffix}`, + `${preferred}/opt/openjdk@25${suffix}`, + `${other}/opt/openjdk${suffix}`, + `${other}/opt/openjdk@21${suffix}`, + `${other}/opt/openjdk@25${suffix}`, + ]; + for (const selected of [0, 1, 2, 3, undefined]) { + const commands: string[] = []; + const inspection = await inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: pathJava ? "/usr/bin" : "/empty-bin" }, + installDir: INSTALL, + platform: "darwin", + architecture, + }, + host({ + readText: () => + "application.version=12.1.4\napplication.java.min=21\napplication.java.max=25\n", + directoryEntries: () => ["openjdk@25", "openjdk@21"], + executable: (candidate) => + (pathJava && candidate === "/usr/bin/java") || + candidates.includes(candidate) || + candidate.endsWith("/mac_x86_64/decompile") || + host().executable(candidate), + probeJava: (command) => { + commands.push(command); + return selected !== undefined && command === candidates[selected] + ? javaProbeOk({ + ...JAVA, + home: command.slice(0, -9), + major: selected === 2 ? 25 : 21, + version: selected === 2 ? "25.0.1" : JAVA.version, + osName: "Mac OS X", + osArch: architecture === "arm64" ? "aarch64" : "x86_64", + }) + : missingJavaProbe; + }, + }), + ); + expect(commands).toEqual([ + ...(pathJava ? ["/usr/bin/java"] : []), + ...candidates.slice( + 0, + selected === undefined ? undefined : selected + 1, + ), + ]); + expect(inspection.status).toBe( + selected === undefined ? "unavailable" : "available", + ); + if (selected !== undefined) + expect(inspection.javaCommand).toBe(candidates[selected]); + } + }, +); + +it.each([ + "explicit", + "path", + "missing", + "wrong version", + "wrong architecture", + "cancelled", +] as const)( + "preserves Java selection and admission during versioned discovery: %s", + async (scenario) => { + const directoryEntries = vi.fn(() => [ + "openjdk@17", + "openjdk@21", + "openjdk@999", + "openjdk@21/../foreign", + ]); + const keg = + "/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home/bin/java"; + const commands: string[] = []; + const controller = new AbortController(); + const result = inspectGhidraInstallation( + { + resourceOwner: inspectionResources.owner, + environment: { PATH: "/usr/bin" }, + installDir: INSTALL, + platform: "darwin", + architecture: "arm64", + signal: controller.signal, + ...(scenario === "explicit" ? { javaHome: "/selected-jdk" } : {}), + }, + host({ + readText: () => + "application.version=12.1.4\napplication.java.min=21\napplication.java.max=21\n", + directoryEntries, + executable: (candidate) => + candidate === "/usr/bin/java" || + candidate === keg || + host().executable(candidate), + probeJava: (command) => { + commands.push(command); + if (scenario === "cancelled" && command === keg) { + controller.abort("controlled cancellation"); + throw new AnalysisCancelledError("ghidra:installation"); + } + if ( + scenario === "explicit" || + scenario === "missing" || + (scenario !== "path" && command !== keg) + ) + return missingJavaProbe; + return javaProbeOk({ + ...JAVA, + home: "/selected-jdk", + major: scenario === "wrong version" ? 17 : 21, + version: scenario === "wrong version" ? "17.0.1" : "21.0.11", + osName: "Mac OS X", + osArch: scenario === "wrong architecture" ? "x86_64" : "aarch64", + }); + }, + }), + ); + if (scenario === "cancelled") { + await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError); + } else { + const inspection = await result; + expect(inspection.status).toBe( + scenario === "path" ? "available" : "unavailable", + ); + if (scenario === "wrong version" || scenario === "wrong architecture") + expect(inspection.javaCommand).toBe(keg); + } + if (scenario === "explicit" || scenario === "path") { + expect(directoryEntries).not.toHaveBeenCalled(); + expect(commands).toEqual([ + scenario === "explicit" ? "/selected-jdk/bin/java" : "/usr/bin/java", + ]); + } else expect(commands).toEqual(["/usr/bin/java", keg]); + }, +); + it("explains the macOS keg-only JDK case without probing an unselected installation", async () => { const probed: string[] = []; const inspection = await inspectGhidraInstallation( diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index e3b49766b4..474e0a612d 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -19,7 +19,13 @@ import { readGhidraInstallationProperties, type GhidraInstallationFileOptions, } from "./GhidraInstallationProperties.js"; -import { constants, accessSync, realpathSync, statSync } from "node:fs"; +import { + constants, + accessSync, + readdirSync, + realpathSync, + statSync, +} from "node:fs"; import { delimiter as pathDelimiter, dirname, posix, win32 } from "node:path"; import type { ProviderRejectionCode } from "../domain/providerSelection.js"; @@ -274,6 +280,8 @@ export interface GhidraInstallationHost { options: GhidraInstallationFileOptions, ): string | undefined | Promise; executable(path: string): boolean; + /** Optional best-effort enumeration of installed Homebrew opt aliases. */ + directoryEntries?(path: string): readonly string[]; /** * Read a bounded header prefix of one executable so admission can reject a * tool built for another platform or architecture. An omitted seam, or an @@ -512,17 +520,35 @@ const installationCoordinates = async ( nativeDecompilerHeader.error.cleanupIncomplete) ) throw nativeDecompilerHeader.error; + const parsedProperties = + properties === undefined + ? undefined + : ghidraApplicationProperties(properties); + const javaRequirement = ghidraJavaRequirement(parsedProperties); const homebrewJavaCandidates = () => (architecture === "arm64" ? ["/opt/homebrew", "/usr/local"] : ["/usr/local", "/opt/homebrew"] ) - .map((prefix) => - path.join( - prefix, - "opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java", - ), - ) + .flatMap((prefix) => { + const opt = path.join(prefix, "opt"); + const versioned = (host.directoryEntries?.(opt) ?? []) + .filter((name) => /^openjdk@[1-9][0-9]*$/u.test(name)) + .map((name) => ({ + name, + major: Number(name.slice("openjdk@".length)), + })) + .filter( + ({ major }) => + Number.isSafeInteger(major) && + ghidraJavaMajorAccepted(major, javaRequirement), + ) + .sort((left, right) => left.major - right.major) + .map(({ name }) => name); + return ["openjdk", ...versioned].map((formula) => + path.join(opt, formula, "libexec/openjdk.jdk/Contents/Home/bin/java"), + ); + }) .filter((candidate) => host.executable(candidate)); const selectedJava: { command: string; @@ -587,7 +613,7 @@ const installationCoordinates = async ( if ( platform === "darwin" && options.javaHome === undefined && - (selectedJava.source === "path" || selectedJava.source === "default") && + selectedJava.source !== "configured" && javaProbe !== undefined && !javaProbe.ok && !(javaProbe.error instanceof AnalysisError) && @@ -600,6 +626,7 @@ const installationCoordinates = async ( failure: javaProbe.error, }); for (const candidate of homebrewJavaCandidates()) { + if (candidate === selectedJava.command) continue; const fallback = await host.probeJava( candidate, ghidraJavaEnvironment(undefined, options.environment, platform), @@ -629,10 +656,7 @@ const installationCoordinates = async ( analyzeHeadlessPath, nativeDecompilerPath, nativeDecompilerHeader, - properties: - properties === undefined - ? undefined - : ghidraApplicationProperties(properties), + properties: parsedProperties, ...(javaDiscoveryFailures.length === 0 ? {} : { javaDiscoveryFailures }), javaCommand, java: javaProbe?.ok === true ? javaProbe.value : undefined, @@ -1074,6 +1098,14 @@ const javaRemediation = ( export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({ readText: readGhidraInstallationProperties, + directoryEntries(path) { + try { + return readdirSync(path); + } catch { + // Optional discovery cannot replace explicit Java selection or admission. + return []; + } + }, executableHeader: readGhidraExecutableHeader, executable(path) { try { diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 27045790ce..44e2105b5e 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -13,11 +13,15 @@ import { HopperProvider } from "../hopper/HopperProvider.js"; import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js"; import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; -import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js"; +import { + ghidraMipsProfileMismatchReason, + ghidraMipsProfileParameters, +} from "./GhidraMipsProfile.js"; import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js"; import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js"; import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js"; import { + existingGhidraAnalysisProfile, ghidraPinnedLanguage, ghidraProfileLanguageOverride, ghidraProfilePinnedLanguage, @@ -96,6 +100,28 @@ const abi = ( const softFloat = abi({ cpr1Size: 0, fpAbi: 3 }); describe("bounded Ghidra MIPS admission", () => { + it("binds a saved project's MIPS metadata without claiming a fresh-import lane", () => { + const little = target(); + const big = target({ byteOrder: "big" }); + const profile = existingGhidraAnalysisProfile( + little, + { id: "ghidra", name: "Ghidra", version: "12.1.4" }, + { project_name: "fixture" }, + ); + expect(profile.parameters).not.toHaveProperty("mips_support_lane"); + expect( + ghidraMipsProfileMismatchReason(little, profile.parameters, true), + ).toBeNull(); + expect( + ghidraMipsProfileMismatchReason(big, profile.parameters, true), + ).toMatch(/metadata does not match/u); + expect( + ghidraMipsProfileMismatchReason(little, profile.parameters, false), + ).toMatch(/different target/u); + expect(ghidraMipsProfileMismatchReason(little, {}, true)).toMatch( + /metadata does not match/u, + ); + }); it("binds endian and flags without widening Windows P0", async () => { const ghidra = new GhidraProvider(config, silentLogger, {}, host); const little = target(); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index d9838ac65f..4dc2a60d66 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -435,6 +435,7 @@ export const ghidraMipsProfileParameters = ( export const ghidraMipsProfileMismatchReason = ( target: BinaryTarget, parameters: Readonly>, + existingProject: boolean, ): string | null => { if (target.architecture !== "mips" || target.mips === undefined) return null; const expected = ghidraMipsProfileParameters(target); @@ -447,6 +448,7 @@ export const ghidraMipsProfileMismatchReason = ( const expectedBasis = committedLane === pspLane ? undefined : expected.mips_abi_basis; if ( + !existingProject && expectedLane !== undefined && (committedLane !== expectedLane || parameters.mips_abi_basis !== expectedBasis) diff --git a/src/ghidra/GhidraProjectSnapshot.ts b/src/ghidra/GhidraProjectSnapshot.ts index 75f85566e6..6257dbc0c4 100644 --- a/src/ghidra/GhidraProjectSnapshot.ts +++ b/src/ghidra/GhidraProjectSnapshot.ts @@ -23,11 +23,16 @@ import { RegularFileChangedError, } from "../filesystem/RegularFile.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import type { JsonValue } from "../domain/jsonValue.js"; import type { GhidraProjectSelection } from "../config/ghidraExistingProject.js"; import { ghidraProgramNameSchema } from "../config/ghidraExistingProject.js"; +/** Reason for a selected project whose content no longer matches its committed profile. */ +export const GHIDRA_PROJECT_PROFILE_MISMATCH = + "Selected Ghidra project content no longer matches the profile committed by open_binary; when it changed is not established"; + /** Classify file cancellation/cleanup before the generic provider boundary. */ const projectFileOperation = async ( path: string, @@ -296,7 +301,13 @@ export const copyExistingGhidraProject = async ( signal, )) !== expectedSha256 ) - throw new RegularFileChangedError(sourcePath); + // A digest mismatch shows only that the project differs from its + // committed profile, not when it changed relative to this copy. + throw new AnalysisArtifactChangedError( + "open_binary", + sourcePath, + GHIDRA_PROJECT_PROFILE_MISMATCH, + ); } }); }; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index e5728fe7f9..4018194eb9 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -3,6 +3,12 @@ import { EXISTING_PROJECT_WINDOWS_REMEDIATION, EXISTING_PROJECT_WINDOWS_UNAVAILABLE, existingProjectCapabilities, + ghidraEmptyIncomingReferencesLimitation, + ghidraEmptyDossierIncomingLimitation, + ghidraEmptyExternalOutgoingLimitation, + ghidraEmptyInstructionOutgoingLimitation, + ghidraEmptyOutgoingReferencesLimitation, + ghidraEmptyXrefsLimitation, } from "./GhidraProviderCapabilities.js"; import { AnalysisProviderRegistry } from "../application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../application/binary/BinarySessionComposition.js"; @@ -44,6 +50,16 @@ import { nativeAotPeVirtualSectionFixture, } from "../../tests/fixtures/nativeaotPe.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { functionDossierSchema } from "../domain/native/functionDossier.js"; +import { + compactInstruction, + compactReference, +} from "../domain/native/nativeInstruction.fixture.js"; +import { + ghidraFunctionDossier, + ghidraFunctionIdentity, + ghidraReferenceEdge, +} from "../domain/ghidraValues.fixture.js"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -59,7 +75,12 @@ import { ghidraStartupTimeoutGuidance, type GhidraProviderClientFactory, } from "./GhidraProviderClient.js"; -import type { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; +import { + GhidraLaunchError, + type GhidraHeadlessLauncher, +} from "./GhidraLauncher.js"; +import { AnalysisArtifactChangedError } from "../domain/analysisErrorCore.js"; +import { GHIDRA_PROJECT_PROFILE_MISMATCH } from "./GhidraProjectSnapshot.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; import { GHIDRA_MUTATING_OPERATIONS, @@ -68,6 +89,7 @@ import { } from "./GhidraSessionValues.js"; import type { Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { silentLogger } from "../logger.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -1354,24 +1376,153 @@ describe("Ghidra result projection", () => { }); }); -describe("Ghidra file-offset outcome projection", () => { - const remoteFailure = (remoteCode: string, message: string) => - provider(installationHost(), () => ({ - start: () => Promise.resolve(ok(sessionInfo())), - callTool: () => - Promise.resolve( +const remoteFailure = (remoteCode: string, message: string) => + provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => + Promise.resolve( + err( + new GhidraSessionError( + "remote", + message, + { remote_code: remoteCode }, + { remoteCode }, + ), + ), + ), + close: () => Promise.resolve(ok(null)), + })); + +describe("Ghidra procedure lookup projection", () => { + it.each([ + { + code: "procedure_not_found", + selector: "Unresolved_Selected_Name", + message: + "Unknown Ghidra procedure name or address: Unresolved_Selected_Name", + remedy: "list_procedures", + qualification: "does not prove", + }, + { + code: "file_offset_unmapped", + selector: "file-offset:0xdeadbeef", + message: "File offset 0xdeadbeef has no loaded source mapping", + remedy: "inspect_native_load_image", + qualification: "does not prove", + }, + { + code: "procedure_ambiguous", + selector: "overloaded", + message: + "Ghidra procedure name is ambiguous: overloaded; select an exact entry address from [0x1000, 0x2000]", + remedy: "entry address", + qualification: "reported candidates", + }, + { + code: "file_offset_ambiguous", + selector: "file-offset:0x100", + message: + "File offset 0x100 has multiple loaded mappings: [0x1000, 0x2000]", + remedy: "analysis address", + qualification: "does not select one automatically", + }, + ])( + "reports $code without schema repair", + async ({ code, selector, message, remedy, qualification }) => { + const client = await createElfClient(remoteFailure(code, message)); + try { + const result = await client.execute("procedure_pseudo_code", { + procedure: selector, + }); + if (result.ok) throw new Error("Expected a missing procedure"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "invalid_request", + message, + details: { + issues: [ + { + path: ["procedure"], + observed: { procedure: selector }, + }, + ], + diagnostics: { remote_code: code }, + }, + }); + expect(projected).not.toHaveProperty("recovery"); + expect(projected.remediation.action).toContain(remedy); + expect(projected.remediation.action).toContain(qualification); + expect(projected.remediation.action).not.toContain( + "REA_GHIDRA_PROJECT_PATH", + ); + } finally { + await client.close(); + } + }, + ); + + it("offers an explicit fresh import when the missing procedure came from a selected project", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-missing-procedure-")); + await mkdir(join(root, "selected.rep")); + await writeFile(join(root, "selected.gpr"), "marker"); + const message = "Unknown Ghidra procedure name or address: missing"; + const ghidra = provider( + installationHost(), + () => ({ + start: async () => + ok({ + ...sessionInfo(), + read_only: true, + existing_project: true as const, + analysis_complete: null, + capabilities: GHIDRA_SESSION_CAPABILITIES.filter( + (name) => !GHIDRA_MUTATING_OPERATIONS.has(name), + ), + }), + callTool: async () => err( new GhidraSessionError( "remote", message, - { remote_code: remoteCode }, - { remoteCode }, + {}, + { + remoteCode: "procedure_not_found", + }, ), ), - ), - close: () => Promise.resolve(ok(null)), - })); + close: async () => ok(null), + }), + { REA_GHIDRA_PROJECT_PATH: join(root, "selected.gpr") }, + ); + try { + const client = await createElfClient(ghidra); + try { + const result = await client.execute("procedure_address", { + procedure: "missing", + }); + if (result.ok) throw new Error("Expected a missing procedure"); + const projected = projectAnalysisError(result.error); + expect(projected).not.toHaveProperty("recovery"); + expect(projected.remediation.action).toContain( + "REA_GHIDRA_PROJECT_PATH", + ); + expect(projected.remediation.action).toContain( + "completeness is unknown", + ); + expect(projected.remediation.action).toContain( + "will not include the selected project's annotations or other edits", + ); + } finally { + await client.close(); + } + } finally { + await ghidra.closeOwnedResources(); + await rm(root, { recursive: true, force: true }); + } + }); +}); +describe("Ghidra file-offset outcome projection", () => { it.each([ [ "address_unmapped", @@ -3122,6 +3273,439 @@ const sessionInfo = () => ({ }, }); +describe("Ghidra empty reference diagnostics", () => { + const emptyReferenceProvider = (result: JsonValue) => + provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(result)), + close: () => Promise.resolve(ok(null)), + })); + + const observation = (value: JsonValue) => ({ value, limitations: [] }); + + it("names unresolved references in the limitations of an empty xrefs result", async () => { + const ghidra = emptyReferenceProvider([]); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("xrefs", { + address: "0x1005409a2", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toEqual([]); + expect(execution.value.limitations).toContain(ghidraEmptyXrefsLimitation); + } finally { + await client.close(); + } + }); + + it("does not add the unresolved-references caveat to a non-empty xrefs result", async () => { + const ghidra = emptyReferenceProvider(["0x100003670"]); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("xrefs", { + address: "0x1005409a2", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toEqual(["0x100003670"]); + expect(execution.value.limitations).not.toContain( + ghidraEmptyXrefsLimitation, + ); + } finally { + await client.close(); + } + }); + + it.each(["incoming", "outgoing"] as const)( + "qualifies the empty %s direction in procedure_references limitations", + async (direction) => { + const ghidra = emptyReferenceProvider( + observation({ + procedure: ghidraFunctionIdentity(), + direction, + references: [], + reference_kinds_available: true, + unresolved_calls: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("procedure_references", { + procedure: "0x401000", + direction, + }); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ references: [] }); + const expected = + direction === "outgoing" + ? ghidraEmptyOutgoingReferencesLimitation + : ghidraEmptyIncomingReferencesLimitation; + const otherDirection = + direction === "outgoing" + ? ghidraEmptyIncomingReferencesLimitation + : ghidraEmptyOutgoingReferencesLimitation; + expect(execution.value.limitations).toContain(expected); + expect(execution.value.limitations).not.toContain(otherDirection); + } finally { + await client.close(); + } + }, + ); + + it("does not add the caveat to procedure_references that returned references", async () => { + const ghidra = emptyReferenceProvider( + observation({ + procedure: ghidraFunctionIdentity(), + direction: "outgoing", + references: [ghidraReferenceEdge()], + reference_kinds_available: true, + unresolved_calls: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("procedure_references", { + procedure: "0x401000", + direction: "outgoing", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).not.toContain( + ghidraEmptyIncomingReferencesLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); + + it("names the external-specific outgoing caveat for an external procedure_references result", async () => { + const identity = ghidraFunctionIdentity(); + const ghidra = emptyReferenceProvider( + observation({ + procedure: { + ...identity, + classification: { ...identity.classification, external: true }, + }, + direction: "outgoing", + references: [], + reference_kinds_available: true, + unresolved_calls: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("procedure_references", { + procedure: "0x401000", + direction: "outgoing", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyExternalOutgoingLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); +}); + +describe("Ghidra empty function-reference facets", () => { + const emptyReferenceProvider = (result: JsonValue) => + provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(result)), + close: () => Promise.resolve(ok(null)), + })); + + const observation = (value: JsonValue) => ({ value, limitations: [] }); + + it("names each unresolved direction in the limitations of a dossier without any references", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider( + observation({ + ...dossier, + incoming_references: [], + outgoing_references: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("analyze_function", { + procedure: "0x401000", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyDossierIncomingLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyIncomingReferencesLimitation, + ); + expect(execution.value.limitations).toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); + + it("qualifies the empty incoming direction when a dossier observed only outgoing references", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider( + observation({ ...dossier, incoming_references: [] }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("analyze_function", { + procedure: "0x401000", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyDossierIncomingLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyIncomingReferencesLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); + + it("qualifies the empty outgoing direction when a dossier observed only incoming references", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider( + observation({ + ...dossier, + incoming_references: [ghidraReferenceEdge()], + outgoing_references: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("analyze_function", { + procedure: "0x401000", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyDossierIncomingLimitation, + ); + } finally { + await client.close(); + } + }); + + it("qualifies an external function's empty facets without local-disassembly guidance", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider( + observation({ + ...dossier, + procedure: { + ...dossier.procedure, + classification: { + ...dossier.procedure.classification, + external: true, + }, + }, + incoming_references: [], + outgoing_references: [], + }), + ); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("analyze_function", { + procedure: "0x401000", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyIncomingReferencesLimitation, + ); + expect(execution.value.limitations).toContain( + ghidraEmptyExternalOutgoingLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyDossierIncomingLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); +}); + +describe("Ghidra empty annotation reference facets", () => { + const emptyReferenceProvider = (result: JsonValue) => + provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(result)), + close: () => Promise.resolve(ok(null)), + })); + + it("names each unresolved direction when annotation readback observes no references", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider({ + annotations: { + address: "0x401000", + name: "fixture_main", + comment: null, + inline_comment: null, + }, + dossier: { ...dossier, incoming_references: [], outgoing_references: [] }, + effects: { + scope: "session-analysis-database", + source_bytes_modified: false, + persists_after_close: false, + }, + }); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("annotate_native_function", { + procedure: "0x401000", + name: "fixture_main", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyDossierIncomingLimitation, + ); + expect(execution.value.limitations).toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); + + it("qualifies the empty direction when annotation readback observed one direction only", async () => { + const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); + const ghidra = emptyReferenceProvider({ + annotations: { + address: "0x401000", + name: "fixture_main", + comment: null, + inline_comment: null, + }, + dossier: { + ...dossier, + incoming_references: [ghidraReferenceEdge()], + outgoing_references: [], + }, + effects: { + scope: "session-analysis-database", + source_bytes_modified: false, + persists_after_close: false, + }, + }); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("annotate_native_function", { + procedure: "0x401000", + name: "fixture_main", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.limitations).toContain( + ghidraEmptyOutgoingReferencesLimitation, + ); + expect(execution.value.limitations).not.toContain( + ghidraEmptyIncomingReferencesLimitation, + ); + } finally { + await client.close(); + } + }); +}); + +describe("Ghidra empty instruction reference facets", () => { + const emptyReferenceProvider = (result: JsonValue) => + provider(installationHost(), () => ({ + start: () => Promise.resolve(ok(sessionInfo())), + callTool: () => Promise.resolve(ok(result)), + close: () => Promise.resolve(ok(null)), + })); + + const instruction = (incoming: JsonValue[], outgoing: JsonValue[]) => ({ + ...compactInstruction(outgoing), + incoming_references: incoming, + }); + const nonDecoded = (status: JsonValue, incoming: JsonValue[]) => ({ + ...compactInstruction(), + status, + bytes: null, + length: null, + mnemonic: null, + raw_disassembly: null, + incoming_references: incoming, + }); + const executeInspect = async ( + result: JsonValue, + ): Promise => { + const ghidra = emptyReferenceProvider(result); + const client = await createElfClient(ghidra); + try { + const execution = await client.execute("inspect_native_instruction", { + document: null, + address: "0x401000", + }); + if (!execution.ok) throw execution.error; + return execution.value.limitations; + } finally { + await client.close(); + } + }; + + it("names each unresolved facet when neither incoming nor outgoing references were observed", async () => { + const limitations = await executeInspect(instruction([], [])); + expect(limitations).toContain(ghidraEmptyXrefsLimitation); + expect(limitations).toContain(ghidraEmptyInstructionOutgoingLimitation); + }); + + it("qualifies the empty incoming list when only outgoing references were observed", async () => { + const limitations = await executeInspect( + instruction([], [compactReference()]), + ); + expect(limitations).toContain(ghidraEmptyXrefsLimitation); + expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation); + }); + + it("qualifies the empty outgoing references when only an incoming list was observed", async () => { + const limitations = await executeInspect( + instruction([compactReference()], []), + ); + expect(limitations).toContain(ghidraEmptyInstructionOutgoingLimitation); + expect(limitations).not.toContain(ghidraEmptyXrefsLimitation); + }); + + it("adds no caveat when both instruction reference facets observed references", async () => { + const limitations = await executeInspect( + instruction([compactReference()], [compactReference()]), + ); + expect(limitations).not.toContain(ghidraEmptyXrefsLimitation); + expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation); + }); + + it("keeps only the address caveat when a data address resolves no incoming references", async () => { + const limitations = await executeInspect(nonDecoded("data", [])); + expect(limitations).toContain(ghidraEmptyXrefsLimitation); + expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation); + }); + + it("adds no caveat for a data address whose incoming references were observed", async () => { + const limitations = await executeInspect( + nonDecoded("data", [compactReference()]), + ); + expect(limitations).not.toContain(ghidraEmptyXrefsLimitation); + expect(limitations).not.toContain(ghidraEmptyInstructionOutgoingLimitation); + }); +}); + describe("Ghidra PDB coverage", () => { it.each([ { name: "missing bridge report", pdb: undefined, warns: true }, @@ -3590,6 +4174,46 @@ describe("Ghidra deadline guidance", () => { }); }); +it.each([ + ["import_identity_unavailable", "did not report a valid imported SHA-256"], + ["import_identity_mismatch", "differs from the selected target"], +] as const)( + "reports fresh-import %s without blaming project configuration", + async (code, message) => { + const failure = new GhidraSessionError( + "admission", + "Identity admission failed", + {}, + { + cause: new GhidraAdmissionError(code), + remoteCode: code, + }, + ); + const ghidra = provider(installationHost(), () => ({ + start: async () => err(failure), + callTool: async () => err(failure), + close: async () => ok(null), + })); + onTestFinished(async () => { + await ghidra.closeOwnedResources(); + }); + const target = executableTarget("elf", "x86_64"); + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("Expected profile"); + const result = await ghidra + .createClient(target, profile.value.profile) + .execute("list_documents", {}); + if (result.ok) throw new Error("Expected identity admission failure"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe("execution_failure"); + expect(projected.message).toContain(message); + expect(JSON.stringify(projected.details)).not.toContain( + "REA_GHIDRA_PROJECT_PATH", + ); + }, +); + describe("Ghidra startup deadline for a finished invocation", () => { const startupLimit = { setting: "ghidra.startup_timeout_ms", @@ -3794,6 +4418,79 @@ describe("Ghidra missing project program", () => { }); }); +describe("Ghidra changed project", () => { + const launchFailure = async (cause: unknown) => { + const root = await mkdtemp(join(tmpdir(), "rea-changed-project-")); + onTestFinished(async () => { + await rm(root, { recursive: true, force: true }); + }); + await mkdir(join(root, "selected.rep")); + await writeFile(join(root, "selected.gpr"), "marker"); + const failure = new GhidraSessionError( + "start", + "Ghidra headless launch failed", + { failure_kind: "start" }, + { + cause: new GhidraLaunchError("Ghidra headless launch failed", { + cause, + }), + }, + ); + const ghidra = provider( + installationHost(), + () => ({ + start: async () => err(failure), + callTool: async () => err(failure), + close: async () => ok(null), + }), + { REA_GHIDRA_PROJECT_PATH: join(root, "selected.gpr") }, + ); + onTestFinished(async () => { + await ghidra.closeOwnedResources(); + }); + const target = executableTarget("elf", "x86_64"); + const profile = await ghidra.resolveAnalysisProfile(target); + if (!profile.ok || profile.value.profile === null) + throw new Error("Expected Ghidra analysis profile"); + const result = await ghidra + .createClient(target, profile.value.profile) + .execute("list_documents", {}); + if (result.ok) throw new Error("Expected a Ghidra start failure"); + return { + error: result.error, + projected: projectAnalysisError(result.error), + }; + }; + + it("projects a source project changed before its verified copy as artifact_changed", async () => { + const path = "/projects/selected"; + const reason = GHIDRA_PROJECT_PROFILE_MISMATCH; + const { error, projected } = await launchFailure( + new AnalysisArtifactChangedError("open_binary", path, reason), + ); + expect(projected).toMatchObject({ + code: "artifact_changed", + details: { + operation: "list_documents", + path, + reason, + boundary: "stable-artifact-read", + }, + }); + expect(projected.remediation.action).toBe( + projectAnalysisError( + new AnalysisArtifactChangedError("list_documents", path, reason), + ).remediation.action, + ); + expect(error.cause).toBeInstanceOf(GhidraSessionError); + }); + + it("keeps the generic start failure for other launch failures", async () => { + const { projected } = await launchFailure(new Error("spawn failed")); + expect(projected.code).toBe("execution_failure"); + }); +}); + describe("Ghidra analysis seeds and configured language", () => { const seedSession = async ( report: (sha256: string) => GhidraSeedReport | undefined, diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 999abae9d6..25d5ec9f34 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -51,6 +51,34 @@ const stringInventoryLimitation = const searchLimitation = "Regex mode uses Java Pattern semantics. Matcher or compiler stack exhaustion returns a resource constraint without closing the session; literal mode avoids regex recursion and returns complete matching values."; +/** Attached to an empty xrefs result: absence of resolved references is not proof. */ +export const ghidraEmptyXrefsLimitation = + "Ghidra's ReferenceManager returned no actionable references to this address in this session. An empty list reflects what this analysis resolved, not proof that the address is unreferenced; synthetic entry-point references are omitted. No independent in-product cross-check exists for this empty result: surrounding disassembly can confirm direct references at a code address, and data or string addresses need an external disassembly scan (for example otool -tV, pairing adrp with add or ldr) before concluding there are no xrefs."; + +/** Attached to an empty incoming-reference facet of a function result. */ +export const ghidraEmptyIncomingReferencesLimitation = + "Ghidra's ReferenceManager returned no actionable incoming references for this function in this session. An empty incoming list reflects what this analysis resolved, not proof that nothing references this function; synthetic entry-point references are omitted. Incoming references have no independent in-product check and require scanning other code (for example an external otool -tV adrp/add pairing) before concluding there are none."; + +/** + * Attached to an empty incoming-reference facet of an analyzed-function + * dossier, which omits synthetic entry-point references and excludes sources + * inside the function's own body. + */ +export const ghidraEmptyDossierIncomingLimitation = + "Ghidra's ReferenceManager returned no actionable incoming references from outside this function's body in this session. An empty incoming list reflects what this analysis resolved, not proof that nothing references this function: synthetic entry-point references are omitted before the dossier's body filter, and sources inside the body are excluded (a self-recursive call is one), so the list can be empty even when such edges exist. In-body edges remain visible in the outgoing references and disassembly, and procedure_references(incoming) reads the same ReferenceManager stream without the in-body exclusion (it omits synthetic entry-point references as well) to separate the cases. Sources outside the program's observed references still require an external scan (for example otool -tV, pairing adrp with add or ldr) before concluding there are none."; + +/** Attached to an empty outgoing-reference facet of an external function. */ +export const ghidraEmptyExternalOutgoingLimitation = + "Ghidra's ReferenceManager returned no actionable outgoing references for this external function in this session. An external function's defining body is not part of this program database, so its outgoing list cannot describe what the function references: this program's call sites (its incoming references and callers) and the defining artifact are the checks before concluding anything about its behavior."; + +/** Attached to an empty outgoing-reference facet of a function result. */ +export const ghidraEmptyOutgoingReferencesLimitation = + "Ghidra's ReferenceManager returned no actionable outgoing references for this function in this session. An empty outgoing list reflects what this analysis resolved, not proof that the function references nothing; synthetic entry-point references are omitted. The function's own disassembly is an independent check for direct outgoing references before concluding there are none."; + +/** Attached to an empty outgoing-reference list of one instruction result. */ +export const ghidraEmptyInstructionOutgoingLimitation = + "Ghidra's ReferenceManager returned no actionable outgoing references from this instruction in this session. An empty list reflects what this analysis resolved, not proof that the instruction references nothing. The instruction's own disassembly is an independent check for direct outgoing references (a direct operand naming a target with no recorded reference contradicts the empty result) before concluding there are none."; + /** Limitation text for one admitted operation, including the common base. */ export const limitationsFor = ( operation: string, @@ -95,7 +123,7 @@ export const limitationsFor = ( case "search_bytes": return [ ...common, - "Searches initialized loader-adjusted memory independently per block, in provider address order. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately. Overlapping matches are included; patterns never cross block boundaries. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.", + "Searches contiguous eligible initialized loader-adjusted memory in provider address order, including across adjacent blocks. Bounds and page coverage ends are inclusive. Uninitialized blocks are listed separately and break search runs. Overlapping matches are included. A cursor resumes the same target, profile and query. Cancellation discards the active search and returns no successful partial page.", ]; case "address_to_file_offset": return [ @@ -248,6 +276,10 @@ export const existingProjectCapabilities = ( available: false, reason: "Existing Ghidra project sessions are read-only.", availabilityCode: "open_options_invalid", + userMessage: + "This Ghidra project session is read-only; the requested edit was not applied.", + remediation: + "Continue inspecting the selected project. To make session-only edits, start a separate REA session with REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME unset, then open a fresh Ghidra import. It is a separate ephemeral analysis and does not preserve the selected project's state.", }; return { ...capability, limitations }; }); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 3040df85fc..9464f560da 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -9,6 +9,7 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup. import { artifactFilePrimaryFacts } from "../artifacts/ArtifactFileOperation.js"; import { fileURLToPath } from "node:url"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; +import { GhidraAdmissionError } from "./GhidraAdmissionError.js"; import { createAnalysisExecution, @@ -74,6 +75,7 @@ import { isGhidraFunctionOperation, parseGhidraFunctionInput, parseGhidraFunctionResult, + type GhidraFunctionOperation, } from "./GhidraFunctionValues.js"; import { isGhidraInventoryOperation, @@ -97,12 +99,18 @@ import { } from "./GhidraRiscvProfile.js"; import { ghidraPePdbLimitations } from "./GhidraPeProfile.js"; import { unverifiedGhidraBuildLimitation } from "./GhidraReleasePolicy.js"; -import { GhidraHeadlessLauncher } from "./GhidraLauncher.js"; +import { GhidraHeadlessLauncher, GhidraLaunchError } from "./GhidraLauncher.js"; import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js"; import type { NativeAotPeResult } from "../domain/native/nativeAotPe.js"; import { GhidraNativeAotSnapshotOwner } from "./GhidraNativeAotSnapshotOwner.js"; import { ghidraHealthLimitations, + ghidraEmptyXrefsLimitation, + ghidraEmptyIncomingReferencesLimitation, + ghidraEmptyDossierIncomingLimitation, + ghidraEmptyExternalOutgoingLimitation, + ghidraEmptyInstructionOutgoingLimitation, + ghidraEmptyOutgoingReferencesLimitation, windowsP0Limitations, limitationsFor, } from "./GhidraProviderCapabilities.js"; @@ -155,6 +163,111 @@ interface GhidraExecutionMetadata { readonly relationshipCoverage?: RelationshipCoverage; } +/** The ReferenceManager observation record of one function-reference result. */ +const referenceObservationRecord = ( + normalized: JsonValue, +): Readonly> | undefined => + typeof normalized === "object" && + normalized !== null && + !Array.isArray(normalized) + ? (normalized as Readonly>) + : undefined; + +const isEmptyArray = (value: JsonValue | undefined | null): boolean => + Array.isArray(value) && value.length === 0; + +/** + * Caveats for the empty reference directions observed in one record: each + * empty facet is qualified independently, because the provider's empty answer + * is unresolved, not proof of absence for that direction. External functions + * (no local body) and the in-body-filtered dossier incoming list get wording + * that matches what was actually observed. + */ +const isExternalProcedureRecord = ( + record: Readonly>, +): boolean => { + const procedure = referenceObservationRecord(record.procedure ?? null); + const classification = + procedure === undefined + ? undefined + : referenceObservationRecord(procedure.classification ?? null); + return classification?.external === true; +}; + +const incomingCaveat = (external: boolean): string => + external + ? ghidraEmptyIncomingReferencesLimitation + : ghidraEmptyDossierIncomingLimitation; + +const outgoingCaveat = (external: boolean): string => + external + ? ghidraEmptyExternalOutgoingLimitation + : ghidraEmptyOutgoingReferencesLimitation; + +const emptyReferenceDirectionCaveats = ( + record: Readonly>, +): readonly string[] => { + const external = isExternalProcedureRecord(record); + return [ + ...(isEmptyArray(record.incoming_references) + ? [incomingCaveat(external)] + : []), + ...(isEmptyArray(record.outgoing_references) + ? [outgoingCaveat(external)] + : []), + ]; +}; + +/** Caveat for a procedure_references result that resolved no references. */ +const emptyProcedureReferencesCaveat = ( + record: Readonly>, +): string | undefined => { + if (!isEmptyArray(record.references)) return undefined; + return record.direction === "outgoing" + ? outgoingCaveat(isExternalProcedureRecord(record)) + : ghidraEmptyIncomingReferencesLimitation; +}; + +/** + * Caveats for an empty ReferenceManager observation, in limitation order: + * the provider's empty answer is unresolved, not proof of absence. xrefs + * reports per address; the function-reference facets per empty direction. + */ +const emptyReferenceCaveat = ( + operation: GhidraFunctionOperation, + normalized: JsonValue, +): readonly string[] => { + if (operation === "xrefs") + return isEmptyArray(normalized) ? [ghidraEmptyXrefsLimitation] : []; + const record = referenceObservationRecord(normalized); + if (record === undefined) return []; + if (operation === "procedure_references") { + const caveat = emptyProcedureReferencesCaveat(record); + return caveat === undefined ? [] : [caveat]; + } + if (operation === "analyze_function") + return emptyReferenceDirectionCaveats(record); + if (operation === "annotate_native_function") { + const dossier = referenceObservationRecord(record.dossier ?? null); + return dossier === undefined ? [] : emptyReferenceDirectionCaveats(dossier); + } + if (operation === "inspect_native_instruction") { + // The incoming list and the outgoing references are independent + // ReferenceManager facets; each empty facet is qualified on its own. + // The outgoing list is only collected for a decoded instruction — other + // statuses return before the ReferenceManager is queried at all. + return [ + ...(isEmptyArray(record.incoming_references) + ? [ghidraEmptyXrefsLimitation] + : []), + ...(record.status === "decoded" && isEmptyArray(record.references) + ? [ghidraEmptyInstructionOutgoingLimitation] + : []), + ]; + } + return []; +}; + /** Build one AnalysisClient for an admitted Ghidra target and profile. */ export const createGhidraProviderClient = (input: { readonly config: AppConfig; @@ -326,6 +439,9 @@ export const createGhidraProviderClient = (input: { const mipsProfileMismatch = ghidraMipsProfileMismatchReason( target, committedProfile.parameters, + project !== undefined && + committedProfile.parameters.import_mode === + "existing-project-private-copy-v1", ); if (mipsProfileMismatch !== null) return err( @@ -846,6 +962,21 @@ export const createGhidraProviderClient = (input: { : parseGhidraInventoryResult(operation, called.value); if (!result.ok) return result; let { value: normalized, ...observation } = result.value; + let executionMetadata: GhidraExecutionMetadata = observation; + const unresolvedReferences = isGhidraFunctionOperation(operation) + ? emptyReferenceCaveat(operation, normalized) + : []; + if (unresolvedReferences.length > 0) { + // An empty list is the provider's observation, not proof: distinguish + // "no references were resolved" from "there are no references". + executionMetadata = { + ...executionMetadata, + limitations: [ + ...(executionMetadata.limitations ?? []), + ...unresolvedReferences, + ], + }; + } if ( (operation === "search_procedures" || operation === "search_strings") && input.value.mode === "literal" && @@ -856,7 +987,7 @@ export const createGhidraProviderClient = (input: { normalized, input.value.pattern, ); - observation = { ...observation, rawResult: called.value }; + executionMetadata = { ...executionMetadata, rawResult: called.value }; } if ( operation === "inspect_native_data_type" && @@ -881,7 +1012,10 @@ export const createGhidraProviderClient = (input: { const metadata = nativeAotOwner?.readTypeDetail(address); if (metadata !== undefined) { // Preserve the database reply independently of the derived overlay. - observation = { ...observation, rawResult: called.value }; + executionMetadata = { + ...executionMetadata, + rawResult: called.value, + }; normalized = jsonValueSchema.parse({ ...normalized, metadata_recovery: metadata, @@ -889,7 +1023,7 @@ export const createGhidraProviderClient = (input: { } } } - return ok(projectExecution(operation, normalized, observation)); + return ok(projectExecution(operation, normalized, executionMetadata)); }, executeIfReady: async (operation, parameters, options) => { if (operation !== "list_documents" && operation !== "list_procedures") @@ -1067,8 +1201,16 @@ const projectPrimarySessionError = ( ? {} : { partialObservation: failure.partialObservation }), }; + // Launch-time copies, such as the verified existing-project copy, report a + // changed source as the launcher's primary failure; read it through the + // launch wrapper so the refusal keeps its typed projection. + const launchChange = + failure.cause instanceof GhidraLaunchError && + failure.cause.primaryFailure instanceof AnalysisArtifactChangedError + ? failure.cause.primaryFailure + : undefined; const { cause: physicalCause, origin } = artifactFilePrimaryFacts( - failure.cause, + launchChange ?? failure.cause, ); // A client-side ceiling refusal travels as the cause; re-attribute it to this // operation so callers see a typed resource constraint rather than a generic @@ -1101,6 +1243,32 @@ const projectPrimarySessionError = ( physicalCause.reason, context, ); + if ( + failure.kind === "admission" && + failure.cause instanceof GhidraAdmissionError && + (failure.cause.code === "import_identity_unavailable" || + failure.cause.code === "import_identity_mismatch") + ) { + const unavailable = failure.cause.code === "import_identity_unavailable"; + const constraint = unavailable + ? "The Ghidra program did not report a valid imported SHA-256, so REA cannot verify its target identity" + : "The Ghidra program's imported SHA-256 differs from the selected target's SHA-256"; + const remediationAction = unavailable + ? "Select a Ghidra program whose imported identity can be verified, or investigate the missing identity in Ghidra. Configure a different project or program when starting REA. REA did not fall back to a fresh import." + : "Select a target matching the configured program and retry, or configure a matching project or program when starting REA. Resolve any reported cleanup before reopening. REA did not fall back to a fresh import."; + if (limits.ghidraExistingProject !== undefined) + return new ConfigurationError(constraint, { + ...context, + settings: [{ setting: "REA_GHIDRA_PROJECT_PATH", constraint }], + remediationAction, + }); + return new ProviderAdapterError("ghidra", operation, { + ...context, + userMessage: `${constraint}.`, + remediationAction: + "Inspect the retained Ghidra diagnostics and target identity before retrying the import.", + }); + } // A well-formed address that lacks one exact source offset keeps the // bridge's reason and the caller's exact spelling, not schema advice. const fileOffset = @@ -1123,6 +1291,54 @@ const projectPrimarySessionError = ( return new AnalysisInputError(operation, context, [ { path: ["name"], reason: "invalid_value", message: failure.message }, ]); + if ( + failure.kind === "remote" && + (failure.remoteCode === "procedure_not_found" || + failure.remoteCode === "file_offset_unmapped" || + failure.remoteCode === "procedure_ambiguous" || + failure.remoteCode === "file_offset_ambiguous") + ) { + let recovery: string; + switch (failure.remoteCode) { + case "file_offset_unmapped": + recovery = + "Use inspect_native_load_image (CLI: `rea inspect-native-load-image --provider ghidra --json`) to inspect the admitted file's loaded mappings, then select an offset with an observed mapping. A missing mapping does not prove the file contains no relevant data."; + break; + case "file_offset_ambiguous": + recovery = + "Choose an explicit analysis address from the reported mappings. Use inspect_native_load_image to inspect the loaded mappings; REA does not select one automatically."; + break; + case "procedure_ambiguous": + recovery = + "Choose an explicit procedure entry address from the reported candidates. Use list_procedures or search_procedures to inspect the current Ghidra inventory."; + break; + default: + recovery = + "Use list_procedures or search_procedures (CLI: `rea search --kind procedures --provider ghidra --json`) to inspect the current Ghidra inventory and choose an observed name or address. An absent entry does not prove the function is absent from the artifact." + + (limits.ghidraExistingProject !== undefined + ? " The selected project's previous analysis completeness is unknown. To explicitly request a separate fresh import, start a new REA process without REA_GHIDRA_PROJECT_PATH and REA_GHIDRA_PROGRAM_NAME; that analysis will not include the selected project's annotations or other edits." + : ""); + } + return new AnalysisInputError( + operation, + { + ...context, + userMessage: failure.message, + conformsToInputContract: true, + remediationAction: recovery, + }, + [ + { + path: ["procedure"], + reason: "invalid_value", + message: failure.message, + ...(typeof request?.procedure === "string" + ? { observed: { procedure: request.procedure } } + : {}), + }, + ], + ); + } if ( failure.kind === "cancelled" || (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") @@ -1214,6 +1430,26 @@ const projectPrimarySessionError = ( }, ); } + if ( + failure.kind === "remote" && + failure.remoteCode === "invalid_regex_pattern" + ) + return new AnalysisInputError( + operation, + { + ...context, + userMessage: failure.message, + remediationAction: + "Correct the pattern using the reported Ghidra regex syntax error, or select mode literal to search for the text literally, then retry.", + }, + [ + { + path: ["pattern"], + reason: "invalid_format", + message: failure.message, + }, + ], + ); if (failure.kind === "remote" && isGhidraInputFailureCode(failure.remoteCode)) return new AnalysisInputError(operation, context, [ { path: [], reason: "invalid_value", message: failure.message }, diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index bf5aa9fd1d..c365dee098 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -7,12 +7,13 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt import { GhidraProvider } from "./GhidraProvider.js"; // Digests commit to the current shared policy, including the instruction -// status, load-image relocation-symbol and procedure search revisions that -// prevent reuse of snapshots with earlier results. +// status, load-image relocation-symbol, procedure search, byte-search and +// bit-field base-type revisions that prevent reuse of snapshots with earlier +// results. const RAW_DIGESTS = { - "6502": "7850ec8d885a9b5e1e6791637b57dfcea2d4359d55754ceeaf858fc43b22293d", + "6502": "6f904539c00521b04f76a3bfd1ca88275e080b5751f9e2305de66c20d0bd2fa0", "x86-disabled-analyzer": - "55fb2b19e10d32bea95a88a097029ab2ad705b4b81dc74844662191799dbb161", + "5a534d959baf34abdafaea64ae9fcb9126553802d42ffa5e4bc0426de5f0af8c", } as const; const configFor = (environment: NodeJS.ProcessEnv) => { @@ -48,6 +49,8 @@ describe("raw-binary analysis profile digest", () => { load_image_observations: "source-mappings-entry-context-relocation-symbol-v3", instruction_status: "unlisted-address-without-decode-v1", + byte_search_boundary: "contiguous-eligible-initialized-blocks-v1", + data_type_observations: "bit-field-base-and-lookup-v1", analyzer_preset: "ghidra-default", }); }); diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index 1fe87cb1cf..eda2c72666 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -189,7 +189,14 @@ const createGhidraSessionError = (failure: { /** Bridge input failures share classification with their domain error projection. */ export const isGhidraInputFailureCode = (code: string | undefined): boolean => - code === "invalid_request" || code === "not_found" || code === "ambiguous"; + code === "invalid_request" || + code === "invalid_regex_pattern" || + code === "not_found" || + code === "procedure_not_found" || + code === "file_offset_unmapped" || + code === "procedure_ambiguous" || + code === "file_offset_ambiguous" || + code === "ambiguous"; /** Bind session-failure construction to its causal diagnostic scope. */ export const bindGhidraSessionFailure = diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 2c3e207c9c..ad50ef37dc 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -307,6 +307,16 @@ describe("Ghidra relationship coverage replay", () => { describe("Ghidra snapshot profile admission", () => { it.each([ + { + parameter: "data_type_observations", + current: "bit-field-base-and-lookup-v1", + legacy: undefined, + }, + { + parameter: "byte_search_boundary", + current: "contiguous-eligible-initialized-blocks-v1", + legacy: undefined, + }, { parameter: "instruction_status", current: "unlisted-address-without-decode-v1", @@ -319,14 +329,24 @@ describe("Ghidra snapshot profile admission", () => { }, { parameter: "instruction_references", - current: "exact-address-incoming-without-synthetic-entry-v2", + current: "exact-address-incoming-without-synthetic-entry-v3", + legacy: "exact-address-incoming-without-synthetic-entry-v2", + }, + { + parameter: "instruction_references", + current: "exact-address-incoming-without-synthetic-entry-v3", legacy: "exact-address-incoming-v1", }, { parameter: "instruction_references", - current: "exact-address-incoming-without-synthetic-entry-v2", + current: "exact-address-incoming-without-synthetic-entry-v3", legacy: undefined, }, + { + parameter: "function_references", + current: "complete-body-and-entry-reference-manager-v3", + legacy: "complete-body-and-entry-reference-manager-v2", + }, { parameter: "call_relationship_coverage", current: "function-body-call-coverage-v5", diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index e20f7f1a61..95390f1f04 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -2,6 +2,7 @@ import { GO_BINARY_PROVIDER_IDENTITY } from "./GoBinaryProviderIdentity.js"; import { projectStableArtifactFailure, projectArtifactCloseFailure, + stableArtifactInputRecovery, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; @@ -114,9 +115,11 @@ const failureForPrimary = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 0cdd5b8e2e..996e0a480b 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -11,6 +11,7 @@ import { } from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -145,9 +146,11 @@ const failureForPrimary = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: [field], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: [field], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 056a23332b..f6a5e92553 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -83,6 +83,12 @@ const OPERATION = "analyze_javascript_application"; const PROVIDER = "javascript-static-analysis"; const MIB = 1024 * 1024; const STATIC_CHECKPOINT_RETENTION_EXPANSION = 6; +/** + * Share of the parent's free heap that static checkpoints may fill once the + * result budget is used. Unlike the live heap, the charges also cover application + * relationships that are reserved but not yet materialized. + */ +const STATIC_CAPACITY_SHARE = 0.75; /** The worker runs on this executable; Bun accepts but ignores V8 heap flags. */ const WORKER_HEAP_FLAGS_ENFORCED = javaScriptRuntime(process.versions).id === "node"; @@ -130,10 +136,11 @@ export class JavaScriptAnalysisWorker { #revokedStaticProjections: JavaScriptRevokedStaticProjection[] = []; #nextSourceIndex = 0; #applicationProjectionFailure: AnalysisResourceConstraintError | undefined; - #parentCapacityExhausted = false; + #parentCapacityExhaustion: Readonly> | undefined; #sourcesRemaining: number | undefined; #deadline: ProviderStartupDeadline | undefined; #resultBudgetBytes = 0; + #staticCapacityBytes = 0; /** Null when the runtime enforces the V8 heap flags instead. */ readonly #processMemoryLimitBytes: number | null; readonly #onStdout = (chunk: Buffer | string): void => { @@ -248,7 +255,7 @@ export class JavaScriptAnalysisWorker { this.#staticProjectionReservations = []; this.#revokedStaticProjections = []; this.#nextSourceIndex = 0; - this.#parentCapacityExhausted = false; + this.#parentCapacityExhaustion = undefined; this.#applicationProjectionFailure = undefined; this.#refreshResultBudget(); return true; @@ -262,17 +269,20 @@ export class JavaScriptAnalysisWorker { /** * Reserve host-side control, schema/Evidence work, and a maximum record's - * transient copies. Admission also accounts for retained earlier files. + * transient copies. Everything retained is charged against what is left. + * + * Semantic and application results stop at half of it. Static checkpoints + * may go on to a larger share while the live heap has room for them, so a + * charge for earlier results cannot cost a later source its static facts. */ #refreshResultBudget(): void { - this.#resultBudgetBytes = Math.max( + const capacity = Math.max( 0, - Math.floor( - (this.#remainingParentHeapBytes() - - 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES) / - 2, - ), + this.#remainingParentHeapBytes() - + 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES, ); + this.#resultBudgetBytes = Math.floor(capacity / 2); + this.#staticCapacityBytes = Math.floor(capacity * STATIC_CAPACITY_SHARE); } /** Analyze inert source; hard worker failure does not terminate this process. */ @@ -290,7 +300,7 @@ export class JavaScriptAnalysisWorker { const id = randomUUID(); const sourceIndex = this.#nextSourceIndex++; this.#applicationProjectionFailure = undefined; - this.#parentCapacityExhausted = false; + this.#parentCapacityExhaustion = undefined; this.#revokedStaticProjections = []; if (this.#sourcesRemaining !== undefined) this.#sourcesRemaining = Math.max(0, this.#sourcesRemaining - 1); @@ -388,20 +398,8 @@ export class JavaScriptAnalysisWorker { sourceIndex, response, ); - if (this.#parentCapacityExhausted) - throw new AnalysisResourceConstraintError( - OPERATION, - "memory", - `The parent's result capacity was exhausted after retaining static facts for ${file.path}; remaining sources were not analyzed`, - { - ...this.#limits(), - parent_result_budget_bytes: this.#resultBudgetBytes, - parent_result_budget_exhausted: true, - }, - { - remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION, - }, - ); + const exhausted = this.#parentCapacityFailure(file.path); + if (exhausted !== undefined) throw exhausted; offeredResultBudget = this.#applicationProjectionFailure !== undefined && this.#sourcesRemaining !== undefined && @@ -799,11 +797,21 @@ export class JavaScriptAnalysisWorker { // Application relationships are materialized only after all source files // have been examined. Reclaim their pending reservations to keep room for // later static observations, without relaxing the actual retained bound. - const needed = - retainedBytes - (this.#resultBudgetBytes - this.#estimatedRetainedBytes); + const { heap_size_limit, used_heap_size } = getHeapStatistics(); const liveAvailable = - this.#remainingParentHeapBytes() - + heap_size_limit - + used_heap_size - 24 * JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES; + // Past the result budget, the live heap must also keep room for another + // copy of what it holds, which final graph and Evidence work needs. The + // limit read here counts young-generation capacity that retained objects + // cannot use, so being merely below it does not protect the parent. + const staticCeiling = + retainedBytes + used_heap_size <= liveAvailable + ? this.#staticCapacityBytes + : this.#resultBudgetBytes; + const needed = + retainedBytes - (staticCeiling - this.#estimatedRetainedBytes); if (needed > 0 && retainedBytes <= liveAvailable) { // Plan all revocations first so an insufficient set leaves the earlier // relationships intact rather than sacrificing them for a failed read. @@ -836,12 +844,17 @@ export class JavaScriptAnalysisWorker { // admission so a second read cannot reject after revocations commit. const remaining = Math.max( 0, - Math.min( - this.#resultBudgetBytes - this.#estimatedRetainedBytes, - liveAvailable, - ), + Math.min(staticCeiling - this.#estimatedRetainedBytes, liveAvailable), ); - this.#parentCapacityExhausted = retainedBytes > remaining; + this.#parentCapacityExhaustion = + retainedBytes > remaining + ? { + parent_static_capacity_bytes: staticCeiling, + parent_available_static_capacity_bytes: remaining, + parent_used_heap_bytes: used_heap_size, + static_retention_estimate_bytes: retainedBytes, + } + : undefined; this.#estimatedRetainedBytes += retainedBytes; const expandedBytes = estimateJavaScriptStaticApplicationProjection( facts, @@ -873,6 +886,26 @@ export class JavaScriptAnalysisWorker { return facts; } + #parentCapacityFailure( + path: string, + ): AnalysisResourceConstraintError | undefined { + if (this.#parentCapacityExhaustion === undefined) return undefined; + return new AnalysisResourceConstraintError( + OPERATION, + "memory", + `The parent's result capacity was exhausted after retaining static facts for ${path}; remaining sources were not analyzed`, + { + ...this.#limits(), + parent_result_budget_bytes: this.#resultBudgetBytes, + ...this.#parentCapacityExhaustion, + parent_result_budget_exhausted: true, + }, + { + remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION, + }, + ); + } + #limits() { return { ...(WORKER_HEAP_FLAGS_ENFORCED diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index fb86a7ef9e..f9df8dc7dc 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -350,7 +350,12 @@ export class KunaProjectReader { .slice(text.value.indexOf("\n") + 1) .replace(/\n+$/u, ""), files, - limitations: [], + limitations: + this.project.manifest.inputs.jobs > 1 + ? [ + "Parallel Kuna project export does not propagate callee-first prototypes between workers; function signatures and call arguments may differ from a serial export.", + ] + : [], }); } diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index 33960e3cc7..c7aba09c46 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -265,7 +265,6 @@ export class KunaProvider implements AnalysisProviderCandidate { kuna_sha256: value.sha256, kuna_command: value.configuration.command, kuna_executable: value.executable, - verified_release: value.verified, jobs: value.configuration.jobs, export: "decompile-project", reference_graph: { @@ -282,6 +281,7 @@ export class KunaProvider implements AnalysisProviderCandidate { termination: "any", xrefs: false, }, + value_flow: "explicitly-unavailable-v1", cache_policy: "live", }, ), diff --git a/src/kuna/KunaProviderCapabilities.ts b/src/kuna/KunaProviderCapabilities.ts index d952649cd1..2b652d27b4 100644 --- a/src/kuna/KunaProviderCapabilities.ts +++ b/src/kuna/KunaProviderCapabilities.ts @@ -46,7 +46,7 @@ const KUNA_ENTRY_LIMITATION = /** Provider text is not comparable across providers. */ const KUNA_TEXT_LIMITATION = "Pseudocode and assembly are exact Kuna text, not comparable as equal or different with another provider's text."; -const KUNA_TEXT_CAPACITY_LIMITATION = `Each buffered source facet admits at most ${String(KUNA_FUNCTION_TEXT_BYTES)} bytes before allocation; larger text stays accessible in the local project export.`; +const KUNA_TEXT_CAPACITY_LIMITATION = `Each buffered source facet admits at most ${String(KUNA_FUNCTION_TEXT_BYTES)} bytes before allocation; larger project-export spans remain in the local project export.`; /** The limitations that apply to one operation's answer, and only those. */ export const kunaLimitations = ( @@ -93,7 +93,7 @@ export const kunaLimitations = ( /** Pseudocode answered before the project export finishes differs from the export's. */ export const KUNA_SINGLE_FUNCTION_LIMITATION = - "This pseudocode came from a single-function `kuna decompile` run while the project export was still building. It lacks the export's whole-program prototypes, and a later request for the same procedure can return the export's text."; + "This pseudocode came from a single-function `kuna decompile` run. It lacks the project export's whole-program prototypes and may differ from project-export pseudocode."; /** Advertise Kuna's static, process-launching operations truthfully. */ export const kunaCapabilities = ( diff --git a/src/kuna/KunaRelease.ts b/src/kuna/KunaRelease.ts index a467c488a2..11123df10a 100644 --- a/src/kuna/KunaRelease.ts +++ b/src/kuna/KunaRelease.ts @@ -1,5 +1,5 @@ /** Kuna release whose CLI and project layout REA verified; others report as unverified. */ -export const KUNA_VERIFIED_VERSION = "1.988"; +export const KUNA_VERIFIED_VERSION = "1.990"; /** Stable provider ID shared by readiness, doctor and Evidence. */ export const KUNA_PROVIDER_ID = "kuna"; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index d7123dd106..df5b0fe4d4 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -1,5 +1,5 @@ import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js"; -import { basename } from "node:path"; +import { basename, join } from "node:path"; import { regularFileCancellation, RegularFileCleanupFailure, @@ -89,7 +89,7 @@ import { kunaLimitations, type KunaOperation, } from "./KunaProviderCapabilities.js"; -import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; +import { KUNA_PROVIDER_ID, KUNA_VERIFIED_VERSION } from "./KunaRelease.js"; import { KunaTargetCommands } from "./KunaTargetCommands.js"; import { KunaReferenceQueries } from "./KunaReferenceQueries.js"; import { KunaQueryWork } from "./KunaQueryWork.js"; @@ -239,6 +239,9 @@ const descriptorCleanupError = ( return error; }; +const FAILED_PROJECT_RECORDS_GUIDANCE = + "REA does not expose per-function records from a failed project export."; + /** * How a request that needs the export reports the export's failure. A session * never rebuilds a failed export, so repeating the request cannot succeed; the @@ -258,7 +261,7 @@ const exportUnavailable = ( "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; const next = failure instanceof AnalysisTimeoutError - ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}` + ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms in the environment that starts REA. ${restart}` : failure instanceof AnalysisResourceConstraintError && failure.remediationAction !== undefined ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. @@ -276,18 +279,33 @@ const exportUnavailable = ( diagnostics: { export_error: failure._tag, export_code: exported.code, + failed_project_records: "not_exposed", ...(exported.details === undefined ? {} : { export_details: exported.details }), }, - userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${next}`, + userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${FAILED_PROJECT_RECORDS_GUIDANCE} Inspect any captured diagnostics. ${next}`, }, ); }; +/** Version verification is observation context, not an analysis input. */ +const kunaVerificationLimitations = ( + installation: KunaInstallation, +): readonly string[] => + installation.verified + ? [] + : [ + `Kuna ${installation.version} is not REA's verified ${KUNA_VERIFIED_VERSION} release; its project layout is unverified. Version matching does not authenticate the executable.`, + ]; + /** Kuna names a procedure by its inventory entry or by an exact entry address. */ const procedureSelectorSchema = z.string().min(1); +/** REA's Kuna integration does not supply a def-use model for any procedure. */ +export const KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE = + "REA's Kuna provider does not report native def-use observations."; + /** * One Kuna analysis session for one target. * @@ -614,6 +632,30 @@ export class KunaSessionClient implements AnalysisClient { // component once, including its producing file digest and byte range. const recordedFiles = files.length === 1 ? files[0] : files.length === 0 ? {} : { files }; + const rawSource = + source === null + ? undefined + : source.kind === "single-function" + ? { kind: "single-function" } + : { + kind: "project-export", + key: source.project.key, + directory: source.project.directory, + ...recordedFiles, + }; + const file = files.length === 1 ? files[0] : undefined; + const sourceDescriptor = + source?.kind === "single-function" + ? { kind: "single-function-decompile" as const } + : source?.kind === "project-export" && file?.file_sha256 != null + ? { + kind: "file-range" as const, + path: join(source.project.directory, file.file), + sha256: file.file_sha256, + byte_offset: file.byte_offset, + byte_length: file.byte_length, + } + : undefined; return createAnalysisExecution( answer.result, { @@ -622,6 +664,7 @@ export class KunaSessionClient implements AnalysisClient { version: installation.version, }, { + ...(sourceDescriptor === undefined ? {} : { sourceDescriptor }), // Kuna's identity is in the analysis profile; the raw result records // only where file-backed text came from. ...(source === null && @@ -633,15 +676,7 @@ export class KunaSessionClient implements AnalysisClient { ...(source === null ? {} : { - source: - source.kind === "single-function" - ? { kind: "single-function" } - : { - kind: "project-export", - key: source.project.key, - directory: source.project.directory, - ...recordedFiles, - }, + source: rawSource, project_export: this.#exportState(), }), ...(answer.referenceSource === undefined @@ -652,7 +687,11 @@ export class KunaSessionClient implements AnalysisClient { : { strings: answer.stringSource }), }, }), - limitations: [...operationLimitations, ...answer.limitations], + limitations: [ + ...operationLimitations, + ...kunaVerificationLimitations(installation), + ...answer.limitations, + ], ...(answer.relationshipCoverage === undefined ? {} : { relationshipCoverage: answer.relationshipCoverage }), @@ -679,7 +718,11 @@ export class KunaSessionClient implements AnalysisClient { }; if (this.#project === undefined) return { state: "not-started" }; if (this.#exportFailure !== undefined) - return { state: "failed", reason: this.#exportFailure }; + return { + state: "failed", + reason: this.#exportFailure, + failure_records: "not_exposed", + }; return { state: "building", phase: this.#status?.phase ?? null, @@ -1043,6 +1086,9 @@ export class KunaSessionClient implements AnalysisClient { err( new AnalysisInputError(operation, { userMessage: kunaDataEntryMessage(entry, index.data.get(entry)!), + conformsToInputContract: true, + remediationAction: + "Inspect the data address with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call.", }), ); const selected = (entry: KunaInventoryFunction) => { @@ -1062,6 +1108,9 @@ export class KunaSessionClient implements AnalysisClient { return err( new AnalysisInputError(operation, { userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, + conformsToInputContract: true, + remediationAction: + "Inspect list_procedures and select a discovered entry if it answers your question. An unlisted address is not proof of a function; REA cannot use it as a Kuna procedure in this session. Do not repeat the unchanged call.", }), ); return refused(entry) ? dataRefusal(entry) : selected(entry); @@ -1074,9 +1123,20 @@ export class KunaSessionClient implements AnalysisClient { return dataRefusal(named[0]); return err( new AnalysisInputError(operation, { + conformsToInputContract: true, + remediationAction: + matches.length === 0 && named.length === 0 + ? field === "procedure" + ? "Use search_procedures or list_procedures to inspect Kuna's discovered entries, then select a matching entry. Do not repeat the unchanged call." + : "Inspect the selected target for the intended address and use that address explicitly. Do not repeat the unchanged call." + : matches.length === 0 + ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." + : "Select the intended entry by its exact address. Do not repeat the unchanged call.", userMessage: matches.length === 0 && named.length === 0 - ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + ? field === "procedure" + ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + : `Kuna found no inventory entry named ${selector}.` : matches.length === 0 ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` : `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`, @@ -1176,7 +1236,40 @@ export class KunaSessionClient implements AnalysisClient { options.signal, ); if (outcome === EXPORT_READY) continue; - if (!outcome.ok) return outcome; + if (!outcome.ok) { + const failure = outcome.error; + if ( + failure instanceof AnalysisResourceConstraintError && + failure.resource === "memory" && + failure.reportedLimits?.output_limit_bytes === + KUNA_FUNCTION_TEXT_BYTES && + !failure.cleanupIncomplete && + this.#exportFailure === undefined && + this.#project !== undefined + ) + return err( + new AnalysisResourceConstraintError( + operation, + failure.resource, + failure.reason, + failure.reportedLimits, + { + cause: failure, + capturedOutput: failure.capturedOutput, + diagnostics: failure.diagnostics, + partialObservation: failure.partialObservation, + retryable: true, + remediationAction: + "The standalone output exceeds REA's fixed buffer. Run rea function for this target with Kuna to build the project, then retry this function. If the project cannot supply pseudocode, consider another provider.", + continuingCallerGuidance: { + remediationAction: + "The standalone output exceeds REA's fixed buffer. If the original session is open, wait for its project export, for example by requesting procedure_assembly. Otherwise reopen the target and wait for its project. Then retry the function. If the project cannot supply pseudocode, consider another provider.", + }, + }, + ), + ); + return outcome; + } const exportFailure = await this.#settledExportFailure(); return ok({ result: outcome.value, @@ -1187,6 +1280,7 @@ export class KunaSessionClient implements AnalysisClient { ? [] : [ `The project export failed, so later requests also use single-function runs: ${exportFailure}`, + FAILED_PROJECT_RECORDS_GUIDANCE, ]), ], }); @@ -1442,7 +1536,11 @@ export class KunaSessionClient implements AnalysisClient { referenced_names: [], basic_blocks: [], native_api: null, - native_value_flow: null, + native_value_flow: { + available: false, + reason: KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE, + limitations: [], + }, facet_availability: { pseudocode: text === null @@ -1469,6 +1567,7 @@ export class KunaSessionClient implements AnalysisClient { this.#deadline, this.#heapRequest, ), + ...kunaVerificationLimitations(installation), ...pseudocode.value.limitations, KUNA_RELATIONSHIP_LIMITATION, ...(relationships.ok diff --git a/src/main/shutdown.ts b/src/main/shutdown.ts index 5614bc13a5..124df659e3 100644 --- a/src/main/shutdown.ts +++ b/src/main/shutdown.ts @@ -3,6 +3,12 @@ import type { Logger } from "pino"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerDiagnostics, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../process/ownedDebuggerCleanupError.js"; import type { McpServerHandle, RuntimeDependencies } from "./types.js"; import { MCP_SHUTDOWN_FAILED } from "./messages.js"; @@ -103,10 +109,14 @@ export const reportMcpShutdownFailure = ( "MCP shutdown rejected", ); dependencies.setExitCode(1); - serverLogger.error(MCP_SHUTDOWN_FAILED); + serverLogger.error(retainedDebuggerDiagnostics(cause), MCP_SHUTDOWN_FAILED); dependencies.writeStderr(`${MCP_SHUTDOWN_FAILED}\n`); if (cause instanceof AnalysisError) dependencies.writeStderr(`${projectAnalysisError(cause).message}\n`); + // This process is about to end, so it is the last chance to name them. + const retainedPids = retainedDebuggerProcessIds(cause); + if (retainedPids.length > 0) + dependencies.writeStderr(`${retainedDebuggerRemediation(retainedPids)}\n`); }; const describeShutdownFailure = ( @@ -134,6 +144,9 @@ const describeShutdownFailureNode = ( (typeof code === "number" && Number.isFinite(code)) ? { code } : {}), + ...(cause instanceof OwnedDebuggerCleanupError + ? { retained_debugger_pids: [...cause.retainedProcessIds] } + : {}), ...(cause instanceof AggregateError ? { errors: cause.errors.map((error: unknown) => diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index 504814c704..841e013cb3 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -232,6 +232,14 @@ interface DoctorCatalogExpectation { readonly expectedPromptNames: readonly string[]; } +const listPromptsWhenAdvertised = ( + client: Client, + request: ReturnType, +) => + client.getServerCapabilities()?.prompts + ? client.listPrompts(undefined, request) + : Promise.resolve({ prompts: [] }); + const inspectProductionMcpSession = async ( client: Client, deadline: number, @@ -240,9 +248,13 @@ const inspectProductionMcpSession = async ( budget: McpPhaseBudget, ) => { const request = requestOptions(deadline, signal); + // The SDK logs to stdout when listPrompts is called without the advertised + // capability, which corrupts the doctor's structured output. An absent + // capability is an empty prompt inventory for this diagnostic. + const promptsRequest = listPromptsWhenAdvertised(client, request); const [tools, prompts] = await Promise.all([ client.listTools(undefined, request), - client.listPrompts(undefined, request), + promptsRequest, ]); const sessionTool = tools.tools.find(({ name }) => name === "binary_session"); // A scope without the session family cannot serve request flow at all; diff --git a/src/native/MachoArchitectureSelection.ts b/src/native/MachoArchitectureSelection.ts index 4c1e035e3f..8463faa0ec 100644 --- a/src/native/MachoArchitectureSelection.ts +++ b/src/native/MachoArchitectureSelection.ts @@ -46,33 +46,49 @@ export const machoSubtypeRank = ( return rank < 0 ? policy.subtypes.length : rank; }; -/** Return a tool selector only for an explicitly supported CPU subtype. */ -export const machoKnownCodeSignSelector = ( +/** codesign's architecture name for one FAT CPU identity, when known. */ +const codeSignArchitectureName = ( cpuType: number, cpuSubtype: number, ): string | undefined => { const capabilities = (cpuSubtype >>> 24) & 0xff; - // arm64e pointer authentication and x86_64 LIB64 have known tool selectors. - // Other capability variants may have distinct names; do not collapse them. + const base = machoCpuSubtypeBase(cpuSubtype); + // codesign names arm64e by its base subtype whatever its versioned + // pointer-authentication ABI (CPU_SUBTYPE_PTRAUTH_ABI plus a version), and + // x86_64 LIB64 as x86_64. Other capability variants may have distinct + // names; do not collapse them. if ( capabilities !== 0 && - !( - cpuType === 0x0100000c && - machoCpuSubtypeBase(cpuSubtype) === 2 && - capabilities === 0x80 - ) && - !( - cpuType === 0x01000007 && - machoCpuSubtypeBase(cpuSubtype) === 3 && - capabilities === 0x80 - ) + !(cpuType === 0x0100000c && base === 2 && (capabilities & 0xf0) === 0x80) && + !(cpuType === 0x01000007 && base === 3 && capabilities === 0x80) ) return undefined; return Object.values(MACHO_ARCHITECTURE_POLICIES) .find((policy) => policy.cpuType === cpuType) - ?.subtypes.find( - ({ subtype }) => subtype === machoCpuSubtypeBase(cpuSubtype), - )?.name; + ?.subtypes.find(({ subtype }) => subtype === base)?.name; +}; + +/** + * Return the codesign selector that names exactly the FAT slice at `index`. + * codesign resolves a name to the first slice in table order with that CPU + * type and base subtype, whatever its capability bits. A later slice sharing + * them has no exact selector, even behind an earlier variant whose own name + * is unknown. + */ +export const machoKnownCodeSignSelector = ( + slices: readonly { readonly cpuType: number; readonly cpuSubtype: number }[], + index: number, +): string | undefined => { + const slice = slices[index]; + if (slice === undefined) return undefined; + const base = machoCpuSubtypeBase(slice.cpuSubtype); + return slices.findIndex( + (other) => + other.cpuType === slice.cpuType && + machoCpuSubtypeBase(other.cpuSubtype) === base, + ) === index + ? codeSignArchitectureName(slice.cpuType, slice.cpuSubtype) + : undefined; }; /** Keep the selected lipo record, including its container coordinates. */ diff --git a/src/native/NativeCallObservation.ts b/src/native/NativeCallObservation.ts index 494dab21fc..bbba6c1a6d 100644 --- a/src/native/NativeCallObservation.ts +++ b/src/native/NativeCallObservation.ts @@ -40,6 +40,11 @@ export const NATIVE_CALL_OBSERVATION_LIMITATIONS = [ "The observation window is checked about once a second, and recording pauses the process at every entry, which slows it.", ] as const; +export const NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION = + "The breakpoint location list is incomplete; location_count gives the full matching count."; +export const NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION = + "Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override."; + /** Launch the active Mach-O under LLDB, record requested call entries, then stop it. */ export const observeNativeCalls = async ( target: BinaryTarget, @@ -216,16 +221,13 @@ const nativeCallFacts = ( `Breakpoints ${unresolved.join(", ")} matched no code in any image loaded while observing; their calls could not be observed.`, ]), ...(run.breakpoints.some( - ({ location_count: count, locations }) => locations.length < count, + ({ location_count: count, locations }) => + locations.length < count && !breakpointLocationLimit, ) - ? [ - "Breakpoints with many matching locations list only the first 64; location_count gives the total.", - ] + ? [NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION] : []), ...(breakpointLocationLimit - ? [ - "Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override.", - ] + ? [NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION] : []), ...(trace.stdout === null || trace.stderr === null ? ["One or more target output captures were unavailable."] diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 9269614512..104b3c853a 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -792,13 +792,15 @@ class NativeMacOSClient implements AnalysisClient { ), ); const target = this.target; - const selectedSlice = + const slices = + target.format === "mach-o-universal" ? target.universalSlices : undefined; + const selectedIndex = target.format === "mach-o-universal" - ? target.universalSlices?.find( + ? (slices?.findIndex( (slice) => slice.offset === target.universalSlice.offset, - ) - : undefined; - if (target.format === "mach-o-universal" && selectedSlice === undefined) + ) ?? -1) + : -1; + if (target.format === "mach-o-universal" && selectedIndex < 0) return err( new AnalysisCapabilityUnavailableError( IDENTITY.id, @@ -807,14 +809,11 @@ class NativeMacOSClient implements AnalysisClient { ), ); const selectedArchitecture = - selectedSlice === undefined + slices === undefined || selectedIndex < 0 ? architecture === "x86" ? "i386" : architecture - : machoKnownCodeSignSelector( - selectedSlice.cpuType, - selectedSlice.cpuSubtype, - ); + : machoKnownCodeSignSelector(slices, selectedIndex); if (selectedArchitecture === undefined) return err( new AnalysisCapabilityUnavailableError( @@ -1931,14 +1930,15 @@ const codeSignArchitectures = ( if (target.kind === "executable" && target.format === "mach-o-universal") { if (target.universalSlices === undefined) return { architectures: [], unknownSlices: true }; - const names = target.universalSlices.map(({ cpuType, cpuSubtype }) => - machoKnownCodeSignSelector(cpuType, cpuSubtype), + const slices = target.universalSlices; + // Each known selector names exactly one slice, so known names are unique. + const names = slices.map((_, index) => + machoKnownCodeSignSelector(slices, index), ); const known = names.filter((name): name is string => name !== undefined); return { - architectures: [...new Set(known)], - unknownSlices: - known.length !== names.length || new Set(known).size !== names.length, + architectures: known, + unknownSlices: known.length !== names.length, }; } const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec( diff --git a/src/native/NativeMacOSProviderInput.test.ts b/src/native/NativeMacOSProviderInput.test.ts index 34c855ff64..e588f5c12d 100644 --- a/src/native/NativeMacOSProviderInput.test.ts +++ b/src/native/NativeMacOSProviderInput.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + import { nativeMacOSCapabilities } from "./NativeMacOSProviderMetadata.js"; import { NativeMacOSProvider } from "./NativeMacOSProvider.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -101,22 +102,59 @@ it.each(["inspect_macho", "list_architectures"] as const)( const root = await createTestTempDirectory("rea-macho-lipo-output-"); const path = join(root, "fixture.macho"); await writeFile(path, inspectionImage()); - const client = new NativeMacOSProvider( - {}, - new NativeFixtureRunner({ lipo: "unrecognized slice record\n" }), - "darwin", - ).createClient(await nativeMachoTargetForFile(path)); - const result = await client.execute(operation, {}); - expect(result).toMatchObject({ - ok: false, - error: { - _tag: "AnalysisOutputError", - capturedOutput: { stdout: "unrecognized slice record\n", exit_code: 0 }, - }, + const runner = new NativeFixtureRunner({ + lipo: "unrecognized slice record\n", }); + const run = vi.spyOn(runner, "run"); + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + await nativeMachoTargetForFile(path), + ); + try { + const result = await client.execute(operation, {}); + expect(run).toHaveBeenCalledWith( + "lipo", + ["-detailed_info", path], + expect.any(Object), + ); + expect(run).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + operation, + capturedOutput: { + stdout: "unrecognized slice record\n", + exit_code: 0, + }, + }, + }); + } finally { + expect((await client.close()).ok).toBe(true); + } }, ); +it("refuses a missing Mach-O source before running lipo", async () => { + const root = await createTestTempDirectory("rea-native-lipo-missing-"); + const runner = new NativeFixtureRunner({ + lipo: "unrecognized slice record\n", + }); + const run = vi.spyOn(runner, "run"); + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + nativeMachoTarget(join(root, "missing")), + ); + try { + const result = await client.execute("inspect_macho", {}); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "ProviderAdapterError" }, + }); + expect(run).not.toHaveBeenCalled(); + } finally { + expect((await client.close()).ok).toBe(true); + } +}); + it("declares the signature snapshot write in provider capabilities", () => { expect( nativeMacOSCapabilities("darwin").find( diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 26a1fc901f..913186c749 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -236,9 +237,11 @@ const appleIpsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 8a82254c3e..957465f099 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -8,6 +8,10 @@ import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.j import { compressedElfNoteFixture, compressedElfNoteSectionFixture, + coreAuxvNote, + coreFileNote, + corePrpsinfoNote, + coreSiginfoNote, elfNote, elfNoteFixture, elfNoteTablePastEnd, @@ -702,3 +706,377 @@ it.each([ } }, ); + +it("decodes Linux core dump notes (NT_PRPSINFO, NT_FILE, NT_SIGINFO, NT_AUXV) for 64-bit little-endian", () => { + const notes = Buffer.concat([ + corePrpsinfoNote({ + bits: 64, + little: true, + state: 0, + sname: "R", + pid: 4242, + ppid: 1, + pgrp: 4242, + sid: 4242, + uid: 1001, + gid: 1002, + fname: "test-proc", + psargs: "/usr/bin/test-proc --daemon -v", + }), + coreFileNote( + [ + { + start: 0x400000n, + end: 0x405000n, + file_ofs: 0n, + path: "/usr/bin/test-proc", + }, + { + start: 0x7fff0000n, + end: 0x7fff2000n, + file_ofs: 10n, + path: "/lib/x86_64-linux-gnu/libc.so.6", + }, + ], + 4096, + 64, + true, + ), + coreSiginfoNote(11, 1, 0, true), // SIGSEGV, SEGV_MAPERR + coreAuxvNote( + [ + [6, 4096n], // AT_PAGESZ + [3, 0x400040n], // AT_PHDR + [9, 0x401000n], // AT_ENTRY + [999, 0x1234n], // Unknown auxv tag + ], + 64, + true, + ), + ]); + + const bytes = elfNoteFixture({ + bits: 64, + little: true, + type: 4, // ET_CORE + ownership: "program", + notes, + }); + + const report = parse(bytes); + expect(report.entries).toHaveLength(4); + + // 1. NT_PRPSINFO + expect(report.entries[0]).toMatchObject({ + owner: "CORE", + type: 3, + type_name: "core_prpsinfo", + gnu: null, + core: { + prpsinfo: { + state: 0, + pid: 4242, + ppid: 1, + pgrp: 4242, + sid: 4242, + uid: 1001, + gid: 1002, + filename: "test-proc", + psargs: "/usr/bin/test-proc --daemon -v", + }, + file: null, + siginfo: null, + auxv: null, + }, + }); + + // 2. NT_FILE + expect(report.entries[1]).toMatchObject({ + owner: "CORE", + type: 0x46494c45, + type_name: "core_file", + gnu: null, + core: { + prpsinfo: null, + file: { + count: 2, + page_size: 4096, + mappings: [ + { + start_address: "0x400000", + end_address: "0x405000", + file_offset_pages: "0x0", + path: "/usr/bin/test-proc", + path_hex: Buffer.from("/usr/bin/test-proc").toString("hex"), + }, + { + start_address: "0x7fff0000", + end_address: "0x7fff2000", + file_offset_pages: "0xa", + path: "/lib/x86_64-linux-gnu/libc.so.6", + path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString( + "hex", + ), + }, + ], + }, + siginfo: null, + auxv: null, + }, + }); + + // 3. NT_SIGINFO + expect(report.entries[2]).toMatchObject({ + owner: "CORE", + type: 0x53494749, + type_name: "core_siginfo", + gnu: null, + core: { + prpsinfo: null, + file: null, + siginfo: { + signo: 11, + errno: 0, + code: 1, + }, + auxv: null, + }, + }); + + // 4. NT_AUXV + expect(report.entries[3]).toMatchObject({ + owner: "CORE", + type: 6, + type_name: "core_auxv", + gnu: null, + core: { + prpsinfo: null, + file: null, + siginfo: null, + auxv: [ + { type: 6, type_name: "AT_PAGESZ", value: "0x1000" }, + { type: 3, type_name: "AT_PHDR", value: "0x400040" }, + { type: 9, type_name: "AT_ENTRY", value: "0x401000" }, + { type: 999, type_name: null, value: "0x1234" }, + ], + }, + }); +}); + +it("decodes Linux core dump notes for 32-bit big-endian", () => { + const notes = Buffer.concat([ + corePrpsinfoNote({ + bits: 32, + little: false, + state: 1, + pid: 123, + ppid: 1, + pgrp: 123, + sid: 123, + uid: 500, + gid: 500, + fname: "proc32", + psargs: "proc32 arg", + }), + coreFileNote( + [ + { + start: 0x10000, + end: 0x12000, + file_ofs: 0, + path: "/bin/proc32", + }, + ], + 4096, + 32, + false, + ), + coreAuxvNote( + [ + [6, 4096], + [11, 500], + ], + 32, + false, + ), + ]); + + const bytes = elfNoteFixture({ + bits: 32, + little: false, + type: 4, + ownership: "program", + notes, + }); + + const report = parse(bytes); + expect(report.entries).toHaveLength(3); + expect(report.entries[0]?.core?.prpsinfo).toMatchObject({ + state: 1, + pid: 123, + uid: 500, + gid: 500, + filename: "proc32", + psargs: "proc32 arg", + }); + expect(report.entries[1]?.core?.file).toMatchObject({ + count: 1, + page_size: 4096, + mappings: [ + { + start_address: "0x10000", + end_address: "0x12000", + file_offset_pages: "0x0", + path: "/bin/proc32", + path_hex: Buffer.from("/bin/proc32").toString("hex"), + }, + ], + }); + expect(report.entries[2]?.core?.auxv).toEqual([ + { type: 6, type_name: "AT_PAGESZ", value: "0x1000" }, + { type: 11, type_name: "AT_UID", value: "0x1f4" }, + ]); +}); + +it("tolerates malformed core notes by returning null without throwing", () => { + const truncatedPrpsinfo = elfNote("CORE", 3, Buffer.alloc(20)); // requires 136 for 64-bit + const truncatedFile = elfNote( + "CORE", + 0x46494c45, + Buffer.from("00000000000000010000000000001000", "hex"), // count 1, page_size 4096, but no map entries or filenames + ); + const truncatedSiginfo = elfNote("CORE", 0x53494749, Buffer.alloc(4)); // requires 12 bytes + const unterminatedAuxv = elfNote( + "CORE", + 6, + Buffer.from("00000000000000060000000000001000", "hex"), // AT_PAGESZ=4096 but lacks AT_NULL terminator + ); + const truncatedAuxv = elfNote("CORE", 6, Buffer.alloc(7)); // not multiple of entry size + const notes = Buffer.concat([ + truncatedPrpsinfo, + truncatedFile, + truncatedSiginfo, + unterminatedAuxv, + truncatedAuxv, + ]); + + const bytes = elfNoteFixture({ + bits: 64, + ownership: "program", + notes, + }); + + const report = parse(bytes); + expect(report.entries).toHaveLength(5); + expect(report.entries[0]?.core).toBeNull(); + expect(report.entries[0]?.type_name).toBe("core_prpsinfo"); + expect(report.entries[1]?.core).toBeNull(); + expect(report.entries[1]?.type_name).toBe("core_file"); + expect(report.entries[2]?.core).toBeNull(); + expect(report.entries[2]?.type_name).toBe("core_siginfo"); + expect(report.entries[3]?.core).toBeNull(); + expect(report.entries[3]?.type_name).toBe("core_auxv"); + expect(report.entries[4]?.core).toBeNull(); + expect(report.entries[4]?.type_name).toBe("core_auxv"); +}); + +it("decodes siginfo using target ABI for MIPS machines", () => { + const desc = Buffer.alloc(128); + desc.writeInt32LE(11, 0); // signo = SIGSEGV + desc.writeInt32LE(1, 4); // code = SEGV_MAPERR (offset 4 on MIPS) + desc.writeInt32LE(0, 8); // errno = 0 (offset 8 on MIPS) + const mipsSiginfo = elfNote("CORE", 0x53494749, desc, true); + const bytes = elfNoteFixture({ + bits: 32, + machine: 8, // EM_MIPS + ownership: "program", + notes: mipsSiginfo, + }); + const report = parse(bytes); + expect(report.entries[0]?.core?.siginfo).toEqual({ + signo: 11, + code: 1, + errno: 0, + }); +}); + +it("preserves negative process IDs and signal numbers without rewriting to zero", () => { + const prps = corePrpsinfoNote({ + pid: -1, + ppid: -1, + pgrp: -1, + sid: -1, + }); + const sig = coreSiginfoNote(-1, -2, -3); + const bytes = elfNoteFixture({ + bits: 64, + ownership: "program", + notes: Buffer.concat([prps, sig]), + }); + const report = parse(bytes); + expect(report.entries[0]?.core?.prpsinfo).toMatchObject({ + pid: -1, + ppid: -1, + pgrp: -1, + sid: -1, + }); + expect(report.entries[1]?.core?.siginfo).toMatchObject({ + signo: -1, + code: -2, + errno: -3, + }); +}); + +it("rejects 64-bit auxv tags and out-of-range page sizes with core: null without schema error", () => { + const validAuxv = coreAuxvNote([[6, 4096n]], 64, true); + const wideTagAuxv = Buffer.from(validAuxv); + wideTagAuxv.writeBigUInt64LE(0x100000000n, 20); + + const validFile = coreFileNote( + [ + { + start: 0x400000n, + end: 0x401000n, + file_ofs: 0n, + path: "/bin/app", + }, + ], + 4096, + 64, + true, + ); + const widePageSizeFile = Buffer.from(validFile); + widePageSizeFile.writeBigUInt64LE(0x100000000n, 28); + + const bytes = elfNoteFixture({ + bits: 64, + ownership: "program", + notes: Buffer.concat([wideTagAuxv, widePageSizeFile]), + }); + const report = parse(bytes); + expect(report.entries[0]?.core).toBeNull(); + expect(report.entries[1]?.core).toBeNull(); +}); + +it("does not spend text budget on malformed or discarded NT_FILE paths", () => { + const header = Buffer.alloc(16); + header.writeBigUInt64LE(2n, 0); // count = 2 + header.writeBigUInt64LE(4096n, 8); // page_size = 4096 + const mappings = Buffer.alloc(48); // 2 mappings * 24 bytes + const paths = Buffer.from( + "/bin/app\0unterminated_path_without_null", + "latin1", + ); + const badFileNote = elfNote( + "CORE", + 0x46494c45, + Buffer.concat([header, mappings, paths]), + ); + const bytes = elfNoteFixture({ + bits: 64, + ownership: "program", + notes: badFileNote, + }); + const report = parse(bytes); + expect(report.entries[0]?.core).toBeNull(); +}); diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 4112f9726d..1d60e43add 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -19,6 +19,7 @@ import type { InspectElfNotesInput, } from "../../domain/native/elfNotes.js"; import { + coreNoteTypeName, elfObjectTypeName, gnuNoteTypeName, } from "../../domain/native/elfNotes.js"; @@ -289,6 +290,322 @@ const decodeGnu = ( }; }; +const AUXV_TYPE_NAMES: Readonly> = { + 0: "AT_NULL", + 1: "AT_IGNORE", + 2: "AT_EXECFD", + 3: "AT_PHDR", + 4: "AT_PHENT", + 5: "AT_PHNUM", + 6: "AT_PAGESZ", + 7: "AT_BASE", + 8: "AT_FLAGS", + 9: "AT_ENTRY", + 11: "AT_UID", + 12: "AT_EUID", + 13: "AT_GID", + 14: "AT_EGID", + 15: "AT_PLATFORM", + 16: "AT_HWCAP", + 17: "AT_CLKTCK", + 23: "AT_SECURE", + 25: "AT_RANDOM", + 26: "AT_HWCAP2", + 31: "AT_EXECFN", + 33: "AT_SYSINFO_EHDR", +}; + +const hexWord = (val: bigint | number): string => `0x${val.toString(16)}`; + +const decodeCorePrpsinfo = ( + descriptor: Buffer, + little: boolean, + wide: boolean, + text: TextBudget, +): ElfNotes["entries"][number]["core"] => { + const minLen = wide ? 136 : 124; + if (descriptor.length < minLen) return null; + const u8 = (off: number) => descriptor.readUInt8(off); + const u16 = (off: number) => + little ? descriptor.readUInt16LE(off) : descriptor.readUInt16BE(off); + const u32 = (off: number) => + little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off); + const i32 = (off: number) => + little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off); + + const state = u8(0); + const uid = wide ? u32(16) : u16(8); + const gid = wide ? u32(20) : u16(10); + const pid = wide ? i32(24) : i32(12); + const ppid = wide ? i32(28) : i32(16); + const pgrp = wide ? i32(32) : i32(20); + const sid = wide ? i32(36) : i32(24); + + const fnameOff = wide ? 40 : 28; + const fnameBytes = descriptor.subarray(fnameOff, fnameOff + 16); + const fnameEnd = fnameBytes.indexOf(0); + const fname = ( + fnameEnd < 0 ? fnameBytes : fnameBytes.subarray(0, fnameEnd) + ).toString("latin1"); + chargeText(text, fname.length); + + const psargsOff = wide ? 56 : 44; + const psargsBytes = descriptor.subarray(psargsOff, psargsOff + 80); + const psargsEnd = psargsBytes.indexOf(0); + const psargs = ( + psargsEnd < 0 ? psargsBytes : psargsBytes.subarray(0, psargsEnd) + ).toString("latin1"); + chargeText(text, psargs.length); + + return { + prpsinfo: { + state, + pid, + ppid, + pgrp, + sid, + uid, + gid, + filename: fname, + psargs, + }, + file: null, + siginfo: null, + auxv: null, + }; +}; + +const decodeCoreFile = ( + descriptor: Buffer, + little: boolean, + wide: boolean, + text: TextBudget, +): ElfNotes["entries"][number]["core"] => { + const wordSize = wide ? 8 : 4; + const headerSize = wordSize * 2; + if (descriptor.length < headerSize) return null; + + const readWord = (off: number): bigint => { + if (wide) { + return little + ? descriptor.readBigUInt64LE(off) + : descriptor.readBigUInt64BE(off); + } + const val = little + ? descriptor.readUInt32LE(off) + : descriptor.readUInt32BE(off); + return BigInt(val); + }; + + const countBig = readWord(0); + const pageSizeBig = readWord(wordSize); + if (countBig > 0xffffffffn || pageSizeBig > 0xffffffffn || pageSizeBig === 0n) + return null; + const count = Number(countBig); + const pageSize = Number(pageSizeBig); + + const entrySize = wordSize * 3; + const neededBytes = BigInt(headerSize) + countBig * BigInt(entrySize); + if (BigInt(descriptor.length) < neededBytes) return null; + const tableBytes = Number(neededBytes); + + const rowBytes = count * (wide ? 54 : 30); + if (rowBytes > text.limit - text.used) { + throw new ArtifactReaderFailure( + "limit", + `ELF note text exceeds the ${String(text.limit)}-byte budget.`, + { boundary: "memory" }, + ); + } + + const scanKey = `file:${String(descriptor.byteOffset)}:${String(descriptor.length)}`; + const cachedScan = text.scans.get(scanKey); + if (cachedScan === -1) return null; + if (cachedScan === undefined) { + chargeText(text, descriptor.length - tableBytes, "scanned"); + } + + let strPos = tableBytes; + let totalPathBytes = 0; + for (let i = 0; i < count; i++) { + if (strPos >= descriptor.length) { + text.scans.set(scanKey, -1); + return null; + } + const nullIdx = descriptor.indexOf(0, strPos); + if (nullIdx < 0) { + text.scans.set(scanKey, -1); + return null; + } + totalPathBytes += nullIdx - strPos; + strPos = nullIdx + 1; + } + text.scans.set(scanKey, count); + + const pathBytes = totalPathBytes * 3; + chargeText(text, rowBytes + pathBytes); + + const mappings: { + start_address: string; + end_address: string; + file_offset_pages: string; + path: string; + path_hex: string; + }[] = new Array(count); + + strPos = tableBytes; + for (let i = 0; i < count; i++) { + const entryOff = headerSize + i * entrySize; + const start = readWord(entryOff); + const end = readWord(entryOff + wordSize); + const offsetPages = readWord(entryOff + wordSize * 2); + const nullIdx = descriptor.indexOf(0, strPos); + const pathBuf = descriptor.subarray(strPos, nullIdx); + strPos = nullIdx + 1; + mappings[i] = { + start_address: hexWord(start), + end_address: hexWord(end), + file_offset_pages: hexWord(offsetPages), + path: pathBuf.toString("latin1"), + path_hex: pathBuf.toString("hex"), + }; + } + + return { + prpsinfo: null, + file: { + count, + page_size: pageSize, + mappings, + }, + siginfo: null, + auxv: null, + }; +}; + +const decodeCoreSiginfo = ( + descriptor: Buffer, + little: boolean, + machine: number, +): ElfNotes["entries"][number]["core"] => { + if (descriptor.length < 12) return null; + const i32 = (off: number) => + little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off); + const isMips = machine === 8 || machine === 10; + const signo = i32(0); + const errno = isMips ? i32(8) : i32(4); + const code = isMips ? i32(4) : i32(8); + return { + prpsinfo: null, + file: null, + siginfo: { + signo, + errno, + code, + }, + auxv: null, + }; +}; + +const decodeCoreAuxv = ( + descriptor: Buffer, + little: boolean, + wide: boolean, + text: TextBudget, +): ElfNotes["entries"][number]["core"] => { + const wordSize = wide ? 8 : 4; + const entrySize = wordSize * 2; + if (descriptor.length < entrySize || descriptor.length % entrySize !== 0) + return null; + const totalPairs = descriptor.length / entrySize; + + const scanKey = `auxv:${String(descriptor.byteOffset)}:${String(descriptor.length)}`; + let nullIndex = text.scans.get(scanKey); + if (nullIndex === -1) return null; + + const readWord = (off: number): bigint => { + if (wide) { + return little + ? descriptor.readBigUInt64LE(off) + : descriptor.readBigUInt64BE(off); + } + return BigInt( + little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off), + ); + }; + + if (nullIndex === undefined) { + chargeText(text, descriptor.length, "scanned"); + let found = false; + for (let i = 0; i < totalPairs; i++) { + const off = i * entrySize; + const aTypeBig = readWord(off); + if (aTypeBig > 0xffffffffn) { + text.scans.set(scanKey, -1); + return null; + } + if (aTypeBig === 0n) { + found = true; + nullIndex = i; + for (let j = off + entrySize; j < descriptor.length; j++) { + if (descriptor[j] !== 0) { + text.scans.set(scanKey, -1); + return null; + } + } + break; + } + } + if (!found || nullIndex === undefined) { + text.scans.set(scanKey, -1); + return null; + } + text.scans.set(scanKey, nullIndex); + } + + const rowBytes = nullIndex * (wide ? 38 : 30); + chargeText(text, rowBytes); + + const auxv: { type: number; type_name: string | null; value: string }[] = + new Array(nullIndex); + for (let i = 0; i < nullIndex; i++) { + const off = i * entrySize; + const aType = Number(readWord(off)); + const aVal = readWord(off + wordSize); + auxv[i] = { + type: aType, + type_name: AUXV_TYPE_NAMES[aType] ?? null, + value: hexWord(aVal), + }; + } + + return { + prpsinfo: null, + file: null, + siginfo: null, + auxv, + }; +}; + +const decodeCore = ( + owner: string, + type: number, + descriptor: Buffer, + little: boolean, + wide: boolean, + machine: number, + text: TextBudget, +): ElfNotes["entries"][number]["core"] => { + if (owner !== "CORE") return null; + if (type === 3) return decodeCorePrpsinfo(descriptor, little, wide, text); + if (type === 0x46494c45) + return decodeCoreFile(descriptor, little, wide, text); + if (type === 0x53494749) + return decodeCoreSiginfo(descriptor, little, machine); + if (type === 6) return decodeCoreAuxv(descriptor, little, wide, text); + return null; +}; + /** Decode PT_NOTE and SHT_NOTE tables from one in-memory ELF image. */ export const parseElfNotes = ( bytes: Buffer, @@ -439,7 +756,17 @@ export const parseElfNotes = ( examined, text, )) - parsed.push(toEntry(note, "program", index, reader.little, text)); + parsed.push( + toEntry( + note, + "program", + index, + reader.little, + wide, + header.machine, + text, + ), + ); } } if (shnum !== 0) { @@ -494,7 +821,17 @@ export const parseElfNotes = ( examined, text, )) - parsed.push(toEntry(note, "section", index, reader.little, text)); + parsed.push( + toEntry( + note, + "section", + index, + reader.little, + wide, + header.machine, + text, + ), + ); } } const absent = tables.length === 0; @@ -524,9 +861,12 @@ const toEntry = ( tableKind: "program" | "section", tableIndex: number, little: boolean, + wide: boolean, + machine: number, text: TextBudget, ): Omit => { const gnuOwner = note.owner === "GNU"; + const coreOwner = note.owner === "CORE"; const range = `${String(note.descriptor_offset)}:${String(note.descsz)}`; let digest = text.hashes.get(range); if (digest === undefined) { @@ -541,7 +881,11 @@ const toEntry = ( namesz: note.namesz, descsz: note.descsz, type: note.type, - type_name: gnuOwner ? gnuNoteTypeName(note.type) : "unknown", + type_name: gnuOwner + ? gnuNoteTypeName(note.type) + : coreOwner + ? coreNoteTypeName(note.type) + : "unknown", owner: note.owner, owner_bytes_hex: note.owner_bytes_hex, descriptor: { @@ -554,5 +898,14 @@ const toEntry = ( : null, }, gnu: decodeGnu(note.owner, note.type, note.descriptor, little, text), + core: decodeCore( + note.owner, + note.type, + note.descriptor, + little, + wide, + machine, + text, + ), }; }; diff --git a/src/native/elf/ElfNotes.fixture.ts b/src/native/elf/ElfNotes.fixture.ts index 6d31995d9b..63b2662b42 100644 --- a/src/native/elf/ElfNotes.fixture.ts +++ b/src/native/elf/ElfNotes.fixture.ts @@ -11,7 +11,7 @@ const notePadding = (declared: number): number => (declared === 8 ? 8 : 4); const writeWord = ( bytes: Buffer, offset: number, - value: number, + value: bigint | number, bits: 32 | 64, little: boolean, ): void => { @@ -20,8 +20,8 @@ const writeWord = ( else bytes.writeBigUInt64BE(BigInt(value), offset); return; } - if (little) bytes.writeUInt32LE(value, offset); - else bytes.writeUInt32BE(value, offset); + if (little) bytes.writeUInt32LE(Number(value), offset); + else bytes.writeUInt32BE(Number(value), offset); }; const write16 = ( @@ -40,8 +40,8 @@ const write32 = ( value: number, little: boolean, ): void => { - if (little) bytes.writeUInt32LE(value, offset); - else bytes.writeUInt32BE(value, offset); + if (little) bytes.writeUInt32LE(Number(value), offset); + else bytes.writeUInt32BE(Number(value), offset); }; /** Encode one ELF note, padding name/descriptor to the table alignment. */ @@ -402,3 +402,154 @@ export const elfNoteTablePastEnd = (): Buffer => { writeWord(program, 48, 4, 64, true); return concat(header, program, note); }; + +export interface CorePrpsinfoOptions { + readonly bits?: 32 | 64; + readonly little?: boolean; + readonly state?: number; + readonly sname?: string; + readonly zomb?: number; + readonly nice?: number; + readonly flag?: number; + readonly uid?: number; + readonly gid?: number; + readonly pid?: number; + readonly ppid?: number; + readonly pgrp?: number; + readonly sid?: number; + readonly fname?: string; + readonly psargs?: string; +} + +/** Linux CORE NT_PRPSINFO note (type 3). */ +export const corePrpsinfoNote = (options: CorePrpsinfoOptions = {}): Buffer => { + const bits = options.bits ?? 64; + const little = options.little ?? true; + const desc = Buffer.alloc(bits === 64 ? 136 : 124); + desc[0] = options.state ?? 0; + desc[1] = (options.sname ?? "R").charCodeAt(0); + desc[2] = options.zomb ?? 0; + desc[3] = options.nice ?? 0; + if (bits === 64) { + if (little) desc.writeBigUInt64LE(BigInt(options.flag ?? 0), 8); + else desc.writeBigUInt64BE(BigInt(options.flag ?? 0), 8); + write32(desc, 16, options.uid ?? 1000, little); + write32(desc, 20, options.gid ?? 1000, little); + if (little) { + desc.writeInt32LE(options.pid ?? 1234, 24); + desc.writeInt32LE(options.ppid ?? 1, 28); + desc.writeInt32LE(options.pgrp ?? 1234, 32); + desc.writeInt32LE(options.sid ?? 1234, 36); + } else { + desc.writeInt32BE(options.pid ?? 1234, 24); + desc.writeInt32BE(options.ppid ?? 1, 28); + desc.writeInt32BE(options.pgrp ?? 1234, 32); + desc.writeInt32BE(options.sid ?? 1234, 36); + } + const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1"); + fname.copy(desc, 40, 0, Math.min(fname.length, 16)); + const psargs = Buffer.from( + `${options.psargs ?? "./app --flag"}\0`, + "latin1", + ); + psargs.copy(desc, 56, 0, Math.min(psargs.length, 80)); + } else { + write32(desc, 4, options.flag ?? 0, little); + write16(desc, 8, options.uid ?? 1000, little); + write16(desc, 10, options.gid ?? 1000, little); + if (little) { + desc.writeInt32LE(options.pid ?? 1234, 12); + desc.writeInt32LE(options.ppid ?? 1, 16); + desc.writeInt32LE(options.pgrp ?? 1234, 20); + desc.writeInt32LE(options.sid ?? 1234, 24); + } else { + desc.writeInt32BE(options.pid ?? 1234, 12); + desc.writeInt32BE(options.ppid ?? 1, 16); + desc.writeInt32BE(options.pgrp ?? 1234, 20); + desc.writeInt32BE(options.sid ?? 1234, 24); + } + const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1"); + fname.copy(desc, 28, 0, Math.min(fname.length, 16)); + const psargs = Buffer.from( + `${options.psargs ?? "./app --flag"}\0`, + "latin1", + ); + psargs.copy(desc, 44, 0, Math.min(psargs.length, 80)); + } + return elfNote("CORE", 3, desc, little); +}; + +export interface CoreFileMapping { + readonly start: bigint | number; + readonly end: bigint | number; + readonly file_ofs: bigint | number; + readonly path: string; +} + +/** Linux CORE NT_FILE note (type 0x46494c45). */ +export const coreFileNote = ( + mappings: readonly CoreFileMapping[], + pageSize = 4096, + bits: 32 | 64 = 64, + little = true, +): Buffer => { + const wordBytes = bits === 64 ? 8 : 4; + const headerBytes = 2 * wordBytes; + const mapEntryBytes = 3 * wordBytes; + const filenames = Buffer.concat( + mappings.map((m) => Buffer.from(`${m.path}\0`, "latin1")), + ); + const desc = Buffer.alloc( + headerBytes + mappings.length * mapEntryBytes + filenames.length, + ); + writeWord(desc, 0, mappings.length, bits, little); + writeWord(desc, wordBytes, pageSize, bits, little); + let offset = headerBytes; + for (const m of mappings) { + writeWord(desc, offset, m.start, bits, little); + writeWord(desc, offset + wordBytes, m.end, bits, little); + writeWord(desc, offset + 2 * wordBytes, m.file_ofs, bits, little); + offset += mapEntryBytes; + } + filenames.copy(desc, offset); + return elfNote("CORE", 0x46494c45, desc, little); +}; + +/** Linux CORE NT_SIGINFO note (type 0x53494749). */ +export const coreSiginfoNote = ( + signo = 11, + code = 1, + errno = 0, + little = true, +): Buffer => { + const desc = Buffer.alloc(128); + if (little) { + desc.writeInt32LE(signo, 0); + desc.writeInt32LE(errno, 4); + desc.writeInt32LE(code, 8); + } else { + desc.writeInt32BE(signo, 0); + desc.writeInt32BE(errno, 4); + desc.writeInt32BE(code, 8); + } + return elfNote("CORE", 0x53494749, desc, little); +}; + +/** Linux CORE NT_AUXV note (type 6). */ +export const coreAuxvNote = ( + entries: readonly [key: number, value: bigint | number][], + bits: 32 | 64 = 64, + little = true, +): Buffer => { + const wordBytes = bits === 64 ? 8 : 4; + const desc = Buffer.alloc((entries.length + 1) * 2 * wordBytes); + let offset = 0; + for (const [key, value] of entries) { + writeWord(desc, offset, key, bits, little); + writeWord(desc, offset + wordBytes, value, bits, little); + offset += 2 * wordBytes; + } + writeWord(desc, offset, 0, bits, little); + writeWord(desc, offset + wordBytes, 0, bits, little); + return elfNote("CORE", 6, desc, little); +}; diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index 9efe2d648e..e96de9954f 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -195,13 +196,17 @@ const elfNotesFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index d1a4d4113e..1c83e695cb 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -187,13 +188,17 @@ const kernelModuleFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index e5682bc369..d564ed8826 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -160,9 +161,11 @@ const nativeExportsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeCertificateAsn1.ts b/src/native/pe/PeCertificateAsn1.ts index 1f8985b048..0b8f760798 100644 --- a/src/native/pe/PeCertificateAsn1.ts +++ b/src/native/pe/PeCertificateAsn1.ts @@ -214,7 +214,22 @@ const decodeDirectoryString = ( } }; -const integerHex = (content: Buffer, budget: DecodedNameBudget): string => { +// X.690 8.3.1 and 8.3.2 hold for BER as well as DER: an INTEGER has at least +// one content octet, and its first nine bits are never all zeros or all ones. +// Padded encodings would otherwise collapse onto the minimal serial's hex. +const integerHex = ( + content: Buffer, + budget: DecodedNameBudget, + label: string, +): string => { + if (content.length === 0) return fail(`${label} INTEGER is empty.`); + const second = content[1]; + if ( + second !== undefined && + ((content[0] === 0x00 && second < 0x80) || + (content[0] === 0xff && second >= 0x80)) + ) + return fail(`${label} INTEGER is not minimally encoded.`); let start = 0; while (start < content.length - 1 && content[start] === 0) start += 1; chargeDecodedName(budget, (content.length - start) * 2); @@ -304,12 +319,51 @@ const decodeName = ( return { text, attributes }; }; +// RFC 5280 4.1.2.5 and X.690 11.7-11.8: UTC with seconds, a final "Z", and +// no fractional seconds. UTCTime years 50-99 are 1950-1999, 00-49 are 2000-2049. +const VALIDITY_TIMES = { + 0x17: { + label: "UTCTime", + layout: "YYMMDDHHMMSSZ", + form: /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/u, + }, + 0x18: { + label: "GeneralizedTime", + layout: "YYYYMMDDHHMMSSZ", + form: /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/u, + }, +} as const; + +const isCalendarInstant = ( + year: number, + [month = 0, day = 0, hour = 24, minute = 60, second = 60]: number[], +): boolean => { + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return ( + day >= 1 && + day <= (days[month - 1] ?? 0) && + hour <= 23 && + minute <= 59 && + second <= 59 + ); +}; + const validityTime = (node: Asn1BerNode, budget: DecodedNameBudget): string => { if (node.tag !== 0x17 && node.tag !== 0x18) return fail("Validity instant is not UTCTime or GeneralizedTime."); // A zero byte becomes "\u0000" (six characters) in the hashed evidence JSON. chargeDecodedName(budget, node.content.length * 6); - return node.content.toString("utf8"); + const { label, layout, form } = VALIDITY_TIMES[node.tag]; + // Latin-1 maps each byte to one character, so no invalid byte can match. + const text = node.content.toString("latin1"); + const [year = 0, ...rest] = form.exec(text)?.slice(1).map(Number) ?? []; + if (rest.length === 0) + return fail(`${label} validity instant is not ${layout}.`); + const fullYear = node.tag === 0x17 ? year + (year < 50 ? 2000 : 1900) : year; + if (!isCalendarInstant(fullYear, rest)) + return fail(`${label} validity instant is not a calendar time.`); + return text; }; export interface ParsedCertificate { @@ -430,6 +484,15 @@ export const parseCertificate = ( "TBSCertificate is missing serial, signature, issuer, validity, subject, or subjectPublicKeyInfo.", ); algorithmOid(tbsSignature); + // RFC 5280 4.1.1.2: signatureAlgorithm MUST equal the TBSCertificate + // signature field. Compare encodings, parameters included. + if ( + signatureAlgorithm.tag !== tbsSignature.tag || + !signatureAlgorithm.content.equals(tbsSignature.content) + ) + return fail( + "Certificate signatureAlgorithm differs from the TBSCertificate signature.", + ); requireSubjectPublicKeyInfo(subjectPublicKeyInfo); finishTbsCertificate(tbsChildren, index + 6); const validityChildren = sequence(validity, "Validity"); @@ -444,7 +507,11 @@ export const parseCertificate = ( return { start: node.start, end: node.end, - serial_hex: integerHex(serial.content, names), + serial_hex: integerHex( + serial.content, + names, + "TBSCertificate serialNumber", + ), issuer: decodeName(issuer, names), subject: decodeName(subject, names), not_before: validityTime(notBefore, names), @@ -484,7 +551,11 @@ const signerIdentifier = ( return fail("SignerInfo issuerAndSerialNumber is truncated."); return { issuer: decodeName(name, names), - serial_hex: integerHex(serial.content, names), + serial_hex: integerHex( + serial.content, + names, + "SignerInfo issuerAndSerialNumber serialNumber", + ), subject_key_identifier_hex: null, }; } diff --git a/src/native/pe/PeCertificateParser.test.ts b/src/native/pe/PeCertificateParser.test.ts index 9b6828e812..66bb62bb26 100644 --- a/src/native/pe/PeCertificateParser.test.ts +++ b/src/native/pe/PeCertificateParser.test.ts @@ -515,6 +515,92 @@ it("keeps subjectKeyIdentifier bytes on the signer", () => { ).toThrow(/decoded budget/u); }); +// X.690 8.3.1 and 8.3.2 apply under BER as well as DER. +const malformedSerials = [ + [], + [0x00, 0x00], + [0x00, 0x7f], + [0x00, 0x00, 0x80], + [0xff, 0x80], + [0xff, 0xff], +] as const; +const minimalSerials = [ + [[0x00], "00"], + [[0x7f], "7f"], + [[0x00, 0x80], "80"], + [[0xff], "ff"], + [[0xff, 0x7f], "ff7f"], + [[0x11, 0x22, 0x33, 0x44], "11223344"], +] as const; +const serialName = derSeq( + derSet( + derSeq( + der(0x06, Buffer.from([0x55, 0x04, 0x03])), + der(0x0c, Buffer.from("Fixture")), + ), + ), +); + +it("rejects empty and non-minimal certificate serialNumber INTEGERs", () => { + const certificate = (serial: readonly number[]) => + readAsn1( + derSeq( + derSeq( + der(0x02, Buffer.from(serial)), + SHA256_ALG, + serialName, + derSeq( + der(0x17, Buffer.from("250101000000Z")), + der(0x17, Buffer.from("260101000000Z")), + ), + serialName, + derSeq(SHA256_ALG, der(0x03, Buffer.from([0]))), + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ); + for (const serial of malformedSerials) + expect( + () => parseCertificate(certificate(serial)), + JSON.stringify(serial), + ).toThrow(/TBSCertificate serialNumber INTEGER is (empty|not minimally)/u); + for (const [serial, hex] of minimalSerials) + expect(parseCertificate(certificate(serial)).serial_hex).toBe(hex); +}); + +it("rejects empty and non-minimal SignerInfo serialNumber INTEGERs", () => { + const payload = (serial: readonly number[]) => + contentInfo( + derSeq( + derInt(1), + derSet(SHA256_ALG), + derSeq(DATA_OID), + derSet( + derSeq( + derInt(1), + derSeq(serialName, der(0x02, Buffer.from(serial))), + SHA256_ALG, + SHA256_ALG, + der(0x04, Buffer.from([1])), + ), + ), + ), + ); + for (const serial of malformedSerials) + expect( + () => parse(peCertificateFixture(false, payload(serial))), + JSON.stringify(serial), + ).toThrow( + /SignerInfo issuerAndSerialNumber serialNumber INTEGER is (empty|not minimally)/u, + ); + for (const [serial, hex] of minimalSerials) + expect( + parse(peCertificateFixture(false, payload(serial))).entries[0]?.pkcs7 + ?.signers[0]?.serial_hex, + ).toBe(hex); +}); + it("fails a truncated issuerAndSerialNumber instead of dropping the signer", () => { const signer = derSeq( derInt(1), @@ -700,6 +786,44 @@ it("rejects a UTF8String name that is not valid UTF-8", () => { ).toBe("commonName=é"); }); +it("accepts only RFC 5280 UTC validity instants", () => { + const name = directoryName(der(0x0c, Buffer.from("Fixture"))); + const certificate = (notBefore: Buffer) => + readAsn1( + derSeq( + derSeq( + derInt(1), + SHA256_ALG, + name, + derSeq(notBefore, der(0x17, Buffer.from("260101000000Z"))), + name, + spki, + ), + SHA256_ALG, + der(0x03, Buffer.from([0])), + ), + ); + for (const [tag, content] of [ + [0x17, Buffer.from([0xff, 0xfe])], + [0x17, Buffer.from("2501010000Z")], + [0x17, Buffer.from("250101000000+0800")], + [0x17, Buffer.from("250229000000Z")], + [0x18, Buffer.from("20250101000000.5Z")], + [0x18, Buffer.from("20250101240000Z")], + ] as const) + expect(() => parseCertificate(certificate(der(tag, content)))).toThrow( + PeCertificateAsn1Failure, + ); + // UTCTime 00 is 2000, a leap year; GeneralizedTime carries 2050 onward. + for (const [tag, time] of [ + [0x17, "000229235959Z"], + [0x18, "20500101000000Z"], + ] as const) + expect( + parseCertificate(certificate(der(tag, Buffer.from(time)))).not_before, + ).toBe(time); +}); + it.each([ ["GeneralString", 0x1b, Buffer.from("name")], ["OCTET STRING", 0x04, Buffer.from("name")], @@ -911,3 +1035,28 @@ it.each([ expect(certificate.subject.attributes[0]?.value).toBe(value); }, ); + +it("rejects a certificate whose signatureAlgorithm differs from its TBS signature", () => { + const name = directoryName(der(0x0c, Buffer.from("Fixture"))); + const certificate = (outer: Buffer) => + readAsn1( + derSeq( + derSeq(derInt(1), SHA256_ALG, name, validity, name, spki), + outer, + der(0x03, Buffer.from([0])), + ), + ); + const sha1 = derSeq(der(0x06, Buffer.from([0x2b, 0x0e, 0x03, 0x02, 0x1a]))); + // The same OID with explicit NULL parameters is still a different identifier. + const sha256WithNull = der( + 0x30, + Buffer.concat([SHA256_ALG.subarray(2), der(0x05, Buffer.alloc(0))]), + ); + for (const outer of [sha1, sha256WithNull]) + expect(() => parseCertificate(certificate(outer))).toThrow( + /signatureAlgorithm differs from the TBSCertificate signature/u, + ); + expect( + parseCertificate(certificate(SHA256_ALG)).signature_algorithm_oid, + ).toBe("2.16.840.1.101.3.4.2.1"); +}); diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 47c95deb42..26c0d9a2ca 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -176,13 +177,17 @@ const peCertificatesFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 2e4cf5d452..7324ff68e6 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -203,9 +204,11 @@ const peImportsFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 7087a7e0ef..9131c763d0 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -180,9 +181,11 @@ const peLoadConfigFailurePrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 8bb56154c3..291adb7d51 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,5 +1,6 @@ import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; @@ -165,13 +166,17 @@ const peResourcesFailurePrimary = ( ); } if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index 60b8bc967a..5bfbc1ea1f 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -69,8 +69,12 @@ import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsProviderIdentity.js"; import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; -const HOST_REQUIREMENT = - "The initial real-verified pwntools artifact profile supports Linux x64 only."; +/** Names the detected host so a host refusal is not read as a target refusal. */ +export const pwntoolsHostRequirement = ( + platform: NodeJS.Platform, + arch: string, +): string => + `The real-verified pwntools artifact profile runs only on a Linux x64 host; this host is ${platform}/${arch}. The selected target was not read.`; /** The pwntools profile runs only on Linux x64; discovery and execution share this check. */ export const pwntoolsHostSupported = ( @@ -290,13 +294,18 @@ export class PwntoolsDecoder< throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (!pwntoolsHostSupported(process.platform, process.arch)) + if (!pwntoolsHostSupported(process.platform, process.arch)) { + const requirement = pwntoolsHostRequirement( + process.platform, + process.arch, + ); throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, - HOST_REQUIREMENT, - { userMessage: HOST_REQUIREMENT }, + requirement, + { userMessage: requirement }, ); + } if (!isAbsolute(selectedPath)) throw pwntoolsUnavailable( "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.", diff --git a/src/native/pwntools/PwntoolsFailures.test.ts b/src/native/pwntools/PwntoolsFailures.test.ts index bec9ac4be3..fb3840b066 100644 --- a/src/native/pwntools/PwntoolsFailures.test.ts +++ b/src/native/pwntools/PwntoolsFailures.test.ts @@ -2,6 +2,10 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import { + pwntoolsHostRequirement, + pwntoolsHostSupported, +} from "./PwntoolsDecoder.js"; import { pwntoolsDecoderFailure } from "./PwntoolsFailures.js"; it.each([undefined, null, false, 0, "stop", { stop: true }, new Error("stop")])( @@ -149,3 +153,11 @@ it("retains legacy owner causes without guessing cancellation from error text", "ProviderAdapterError", ); }); + +it("names the refused host instead of implying the target was refused", () => { + expect(pwntoolsHostSupported("linux", "arm64")).toBe(false); + const message = pwntoolsHostRequirement("linux", "arm64"); + expect(message).toContain("Linux x64 host"); + expect(message).toContain("this host is linux/arm64"); + expect(message).toContain("The selected target was not read."); +}); diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index 10da1660c5..721a4869a3 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -3,6 +3,7 @@ import { bindArtifactFileCleanup, } from "../../artifacts/ArtifactFileOperation.js"; import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js"; +import { stableArtifactInputRecovery } from "../../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisError, @@ -274,6 +275,7 @@ const classifyPwntoolsDecoderFailure = ( context.operation, { cause, + ...stableArtifactInputRecovery(cause), ...(cause.reason === "limit" ? { userMessage: `${cause.message} Admitted artifact bytes use REA_PWNTOOLS_MAX_INPUT_BYTES (default ${String(PWNTOOLS_LIMITS.inputBytes)} bytes, maximum 1073741824). The 1073741824-byte maximum has no runtime override. Restart REA after changing the environment value. pwntools_timeout_ms does not raise this allowance. ${fixedPwntoolsBudgets}`, diff --git a/src/objdump/ObjdumpProviderIdentity.ts b/src/objdump/ObjdumpProviderIdentity.ts index aa4d074b42..a039305b08 100644 --- a/src/objdump/ObjdumpProviderIdentity.ts +++ b/src/objdump/ObjdumpProviderIdentity.ts @@ -1,6 +1,6 @@ /** Source-owned static provider identity and public operation names. */ export const OBJDUMP_PROVIDER_IDENTITY = { - id: "gnu.objdump", - name: "GNU objdump", + id: "objdump.command", + name: "objdump command", version: null, } as const; diff --git a/src/process/ArtifactAnalysisResources.test.ts b/src/process/ArtifactAnalysisResources.test.ts index 421621f7d1..b48bc8e023 100644 --- a/src/process/ArtifactAnalysisResources.test.ts +++ b/src/process/ArtifactAnalysisResources.test.ts @@ -4,7 +4,7 @@ import { ArtifactResourceScope, } from "../artifacts/ArtifactResourceScope.js"; import { withArtifactRegularFile } from "../artifacts/ArtifactFileOperation.js"; -import { writeFile } from "node:fs/promises"; +import { symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; @@ -28,6 +28,42 @@ import { ArtifactAnalysisResources } from "./ArtifactAnalysisResources.js"; import { readLinuxResourceLimits } from "./LinuxResourceLimits.js"; import { ProviderProcessSupervisor } from "./ProviderProcess.js"; +it.runIf(process.platform !== "win32")( + "offers a resolved-file retry from the shared selected-artifact boundary", + async () => { + const root = await createTestTempDirectory("rea-selected-artifact-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "fixture"); + await symlink(target, selected); + const resources = new ArtifactAnalysisResources( + "fixture-provider", + "decode_signal_recording", + readStableArtifact, + readLinuxResourceLimits, + { message: "busy", details: {} }, + ); + try { + const result = await resources.run(async () => + ok( + await resources.readSelectedArtifact({ + path: selected, + maximumBytes: 1024, + allowanceName: "fixture input allowance", + observedOutput: undefined, + }), + ), + ); + if (result.ok) throw new Error("Expected selected symlink refusal"); + const projected = projectAnalysisError(result.error); + expect(projected.remediation.action).toContain(target); + expect(projected).not.toHaveProperty("recovery"); + } finally { + await resources.close(); + } + }, +); + it.each(["command", "limiter"])( "attributes post-run %s read failure to its exact artifact and retains producer output", async (failedPath) => { diff --git a/src/process/ArtifactAnalysisResources.ts b/src/process/ArtifactAnalysisResources.ts index aebf66d041..30dc6ae23a 100644 --- a/src/process/ArtifactAnalysisResources.ts +++ b/src/process/ArtifactAnalysisResources.ts @@ -10,6 +10,7 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; import { completedStableArtifactObservation } from "../artifacts/StableArtifactObservation.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { AnalysisError, type AnalysisCapturedOutput, @@ -221,9 +222,16 @@ export class ArtifactAnalysisResources { return new AnalysisAccessDeniedError(operation, path, cause.code, { cause, }); - return new AnalysisInputError(operation, { cause }, [ - { path: ["path"], reason: "invalid_value", message: reason }, - ]); + return new AnalysisInputError( + operation, + { + cause, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, + [{ path: ["path"], reason: "invalid_value", message: reason }], + ); } readLimits(limits: LinuxResourceLimits): Promise { diff --git a/src/process/RegexMatcher.ts b/src/process/RegexMatcher.ts index 028f2e1172..d33fc4baad 100644 --- a/src/process/RegexMatcher.ts +++ b/src/process/RegexMatcher.ts @@ -552,7 +552,6 @@ export const matchRegexValues = async (input: { readonly caseSensitive: boolean; readonly values: readonly string[]; readonly signal?: AbortSignal | undefined; - readonly invalidPattern?: (reason: string) => AnalysisError; /** Resolved deadline. Omitted means the 5000 ms default, not ambient `process.env`. */ readonly deadline?: RegexMatchingDeadline | undefined; /** Resolved heap request. Omitted means the 128 MiB default, not ambient `process.env`. */ @@ -623,20 +622,21 @@ export const matchRegexValues = async (input: { if (reply.data.status === "invalid_pattern") return finish( err( - input.invalidPattern?.(reply.data.reason) ?? - new AnalysisInputError( - input.operation, + new AnalysisInputError( + input.operation, + { + userMessage: `Invalid regex pattern: ${reply.data.reason}`, + remediationAction: + "Correct the pattern using the reported regex syntax error, or select mode literal to search for the text literally, then retry.", + }, + [ { - userMessage: `Invalid regex pattern: ${reply.data.reason}`, + path: ["pattern"], + reason: "invalid_format", + message: reply.data.reason, }, - [ - { - path: ["pattern"], - reason: "invalid_format", - message: "Supply a valid regular expression.", - }, - ], - ), + ], + ), ), ); if ( diff --git a/src/process/capture/ProcessCaptureJournal.test.ts b/src/process/capture/ProcessCaptureJournal.test.ts index 24232c4459..2f8212e611 100644 --- a/src/process/capture/ProcessCaptureJournal.test.ts +++ b/src/process/capture/ProcessCaptureJournal.test.ts @@ -1,6 +1,14 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; -import { createProcessCaptureJournal } from "./ProcessCaptureJournal.js"; +import { + createProcessCaptureJournal, + scheduleScenarioInteractions, +} from "./ProcessCaptureJournal.js"; + +import { TerminalRenderer } from "./TerminalRenderer.js"; +import { processScenarioSchema } from "../../domain/process/processScenario.js"; +import type { InteractionEvent } from "../../domain/process/processCapture.js"; +import type { ProcessTimer } from "./ProcessTimer.js"; it("records capture events in monotonic observation order", () => { const journal = createProcessCaptureJournal(); @@ -16,3 +24,57 @@ it("records capture events in monotonic observation order", () => { { capture_order: 3, collection: "filesystem_checkpoints", index: 1 }, ]); }); + +it.each([true, false])( + "preserves non-path input markers when paths=%s", + async (paths) => { + vi.useFakeTimers(); + const value = "x y\n"; + const scenario = processScenarioSchema.parse({ + executable: "/usr/bin/node", + working_directory: "/workspace", + events: [{ type: "input", at_ms: 0, data: value }], + normalization: { paths, pids: false, ports: false, patterns: [] }, + }); + const renderer = new TerminalRenderer({ + columns: 80, + rows: 24, + scrollback: 0, + maxBytes: 1024, + normalize: (value) => ({ + state: "normalized", + value, + utf8Bytes: Buffer.byteLength(value), + }), + }); + const timers = new Set(); + const interactions: InteractionEvent[] = []; + const journal = createProcessCaptureJournal(); + try { + scheduleScenarioInteractions({ + scenario, + getTerminal: () => undefined, + timers, + interactions, + renderer, + started: 0, + dispatchedEventIndexes: new Set(), + recordEvent: journal.recordEvent, + }); + await vi.advanceTimersByTimeAsync(1); + expect(interactions).toHaveLength(1); + expect(interactions[0]).toMatchObject({ + type: "input", + data: value, + outcome: "target_exited", + }); + expect(journal.entries).toEqual([ + { capture_order: 0, collection: "interaction_events", index: 0 }, + ]); + } finally { + for (const timer of timers) timer.cancel(); + await renderer.dispose(); + vi.useRealTimers(); + } + }, +); diff --git a/src/process/capture/ProcessCaptureJournal.ts b/src/process/capture/ProcessCaptureJournal.ts index fd143b4f36..b379cc8d10 100644 --- a/src/process/capture/ProcessCaptureJournal.ts +++ b/src/process/capture/ProcessCaptureJournal.ts @@ -96,12 +96,7 @@ export const scheduleScenarioInteractions = ( : { data: event.type === "input" - ? normalizeProcessText( - event.data, - scenario, - "", - -1, - ) + ? normalizeProcessText(event.data, scenario, undefined, -1) : event.type === "resize" ? `${String(event.columns)}x${String(event.rows)}` : event.signal, diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index 79693c36bc..49827a8460 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -665,3 +665,40 @@ it("shares sampled identifiers before custom patterns in terminal and command no "root=selected child=selected other=201", ); }); + +it("does not alias the temporary-root absence marker in sampled command data", () => { + const command = "/usr/bin/node --marker "; + const selected = [{ ...samples[0]!, command }]; + const normalized = normalizeProcessSamples( + selected, + scenario({ pids: false, ports: false, patterns: [] }), + rootPid, + ); + expect(normalized[0]?.command).toBe( + " --marker ", + ); + expect(selected[0]?.command).toBe(command); +}); + +it("preserves literal absence text while normalizing selected paths with and without a temporary root", () => { + const selected = scenario({ pids: false, ports: false, patterns: [] }); + const text = + "/usr/bin/node /workspace/file /capture/file"; + for (const temporaryRoot of [undefined, "/capture"]) { + const expected = ` /file ${temporaryRoot === undefined ? "/capture/file" : "/file"}`; + expect( + createProcessTextNormalizer(selected, temporaryRoot, rootPid)(text), + ).toBe(expected); + expect( + createBoundedProcessTextNormalizer( + selected, + temporaryRoot, + rootPid, + )(text, 1024), + ).toEqual({ + state: "normalized", + value: expected, + utf8Bytes: Buffer.byteLength(expected), + }); + } +}); diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index bd2fee817f..7247fae277 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -231,7 +231,7 @@ export const createProcessIdentifierLookup = ( /** Compile one ordered policy for both bounded retention and unrestricted text. */ const createProcessTextRules = ( scenario: ProcessScenario, - temporaryRoot: string, + temporaryRoot: string | undefined, pid: ProcessIdentifierSource, ): (() => readonly (ProcessTextRule | undefined)[]) => { const rules: (ProcessTextRule | undefined)[] = []; @@ -278,7 +278,7 @@ const createProcessTextRules = ( /** Prepare capture-local matchers while retaining a live terminal PID lookup. */ export const createProcessTextNormalizer = ( scenario: ProcessScenario, - temporaryRoot: string, + temporaryRoot: string | undefined, pid: ProcessIdentifierSource, ): ((value: string) => string) => { const rules = createProcessTextRules(scenario, temporaryRoot, pid); @@ -298,7 +298,7 @@ export const createProcessTextNormalizer = ( /** Normalize terminal text without materializing an intermediate over its byte budget. */ export const createBoundedProcessTextNormalizer = ( scenario: ProcessScenario, - temporaryRoot: string, + temporaryRoot: string | undefined, pid: ProcessIdentifierSource, ): ((value: string, maxBytes: number) => BoundedProcessTextNormalization) => { const rules = createProcessTextRules(scenario, temporaryRoot, pid); @@ -339,7 +339,7 @@ export const createBoundedProcessTextNormalizer = ( export const normalizeProcessText = ( value: string, scenario: ProcessScenario, - temporaryRoot: string, + temporaryRoot: string | undefined, pid: number, ): string => createProcessTextNormalizer(scenario, temporaryRoot, pid)(value); @@ -355,12 +355,12 @@ const directoryIdentity = (path: string): string => { /** Match original paths once, preferring the most specific selected identity. */ const createPathRule = ( scenario: ProcessScenario, - temporaryRoot: string, + temporaryRoot: string | undefined, ): | { readonly matcher: RegExp; readonly replace: RegexReplacement } | undefined => { const aliases = new Map(); - const selected: readonly (readonly [string, string])[] = [ + const selected: readonly (readonly [string | undefined, string])[] = [ [temporaryRoot, ""], [directoryIdentity(scenario.working_directory), ""], [scenario.executable, ""], @@ -372,7 +372,8 @@ const createPathRule = ( ), ]; for (const [path, alias] of selected) - if (path !== "" && !aliases.has(path)) aliases.set(path, alias); + if (path !== undefined && path !== "" && !aliases.has(path)) + aliases.set(path, alias); const paths = [...aliases.keys()].sort( (left, right) => right.length - left.length, ); @@ -429,7 +430,7 @@ export const normalizeProcessSamples = ( ); const normalizeText = createProcessTextNormalizer( scenario, - "", + undefined, identifiers, ); const normalizeCommand = (command: string): string => diff --git a/src/process/ownedDebuggerCleanupError.ts b/src/process/ownedDebuggerCleanupError.ts new file mode 100644 index 0000000000..20460a5d76 --- /dev/null +++ b/src/process/ownedDebuggerCleanupError.ts @@ -0,0 +1,45 @@ +/** Closing all sessions could not confirm that these debuggers stopped. */ +export class OwnedDebuggerCleanupError extends AggregateError { + /** Process IDs when cleanup gave up. Observations, not lasting proof of identity. */ + readonly retainedProcessIds: readonly number[]; + + constructor( + failures: readonly Error[], + message: string, + retainedProcessIds: readonly number[], + ) { + super(failures, message); + this.retainedProcessIds = retainedProcessIds; + } +} + +/** Retained debugger process IDs anywhere in a composed cleanup failure. */ +export const retainedDebuggerProcessIds = ( + cause: unknown, +): readonly number[] => { + const found = new Set(); + const visited = new WeakSet(); + const visit = (node: unknown): void => { + if (typeof node !== "object" || node === null || visited.has(node)) return; + visited.add(node); + if (node instanceof OwnedDebuggerCleanupError) + for (const pid of node.retainedProcessIds) found.add(pid); + if (node instanceof AggregateError) + for (const error of node.errors) visit(error); + if (node instanceof Error) visit(node.cause); + }; + visit(cause); + return [...found]; +}; + +/** Log or diagnostic fields naming those processes; empty when none are known. */ +export const retainedDebuggerDiagnostics = ( + cause: unknown, +): { readonly retained_debugger_pids?: number[] } => { + const pids = retainedDebuggerProcessIds(cause); + return pids.length === 0 ? {} : { retained_debugger_pids: [...pids] }; +}; + +/** What a caller must do once REA ends with these debuggers unconfirmed. */ +export const retainedDebuggerRemediation = (pids: readonly number[]): string => + `REA exits without confirming that the debugger stopped, and no later REA command can retry this cleanup. Check process ${pids.join(", ")} and its target, and stop them if they are still running.`; diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 009bb42e82..8ba4b4e2bc 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -4,6 +4,7 @@ import type { DebuggerAdmissionLimit } from "../config/debuggerSessionLimits.js" import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; +import { OwnedDebuggerCleanupError } from "./ownedDebuggerCleanupError.js"; /** Result of terminating one debugger process owned by this application. */ export type OwnedDebuggerStopResult = @@ -364,42 +365,58 @@ export const stopOwnedDebuggerProcess = async ( ); }; +/** What closing all sessions needs from a session it may have to retain. */ +interface RetainableDebuggerSession { + readonly id: string; + readonly launched: { + readonly process: { readonly pid?: number | undefined }; + }; + readonly supervisor: { ref(): void; unref(): void }; +} + /** * Stop every owned session and retain failed sessions for possible recovery. * Callers must block replacement admission and drain in-flight starts until * this settles; incomplete cleanup leaves the old owner in the map for retry. + * + * Closing all sessions means their owner is finishing, so a retained session + * stops holding the host's event loop, as a supervised provider does after + * incomplete cleanup. Ownership in this process is unchanged, and a retry + * restores the references. */ export const closeOwnedDebuggerSessions = async < - Session extends { id: string }, + Session extends RetainableDebuggerSession, >( sessions: Map, stop: (session: Session) => Promise, debuggerName: string, ): Promise => { + const entries = [...sessions.entries()]; + for (const [, session] of entries) session.supervisor.ref(); const results = await Promise.allSettled( - [...sessions.entries()].map(async ([key, session]) => ({ - key, - session, - result: await stop(session), - })), + entries.map(async ([, session]) => stop(session)), ); const failures: Error[] = []; - for (const settled of results) { + const retained: Session[] = []; + for (const [index, settled] of results.entries()) { + const [key, session] = entries[index]!; if (settled.status === "rejected") { failures.push( settled.reason instanceof Error ? settled.reason : new Error(String(settled.reason)), ); - continue; - } - const { key, session, result } = settled.value; - if (result.status === "incomplete") failures.push(new Error(result.reason)); - else if (sessions.get(key) === session) sessions.delete(key); + retained.push(session); + } else if (settled.value.status === "incomplete") { + failures.push(new Error(settled.value.reason)); + retained.push(session); + } else if (sessions.get(key) === session) sessions.delete(key); } - if (failures.length > 0) - throw new AggregateError( - failures, - `One or more owned ${debuggerName} processes did not stop cleanly`, - ); + if (failures.length === 0) return; + for (const session of retained) session.supervisor.unref(); + throw new OwnedDebuggerCleanupError( + failures, + `One or more owned ${debuggerName} processes did not stop cleanly`, + retained.flatMap(({ launched }) => launched.process.pid ?? []), + ); }; diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index a95fc5cb6c..488e44f625 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -1,6 +1,7 @@ import { PYTHON_PYC_PROVIDER_IDENTITY } from "./PythonPycProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; @@ -126,13 +127,17 @@ const failureForPrimary = ( }, ); if (cause.reason === "path" || cause.reason === "format") - return new AnalysisInputError(OPERATION, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") diff --git a/src/recordedMcpCatalog.ts b/src/recordedMcpCatalog.ts index 23595540fe..e53b064340 100644 --- a/src/recordedMcpCatalog.ts +++ b/src/recordedMcpCatalog.ts @@ -16,6 +16,8 @@ export interface RecordedMcpCatalog { readonly instructions: string; readonly identity: ReturnType; readonly tools: readonly Tool[]; + /** Tool names derived from canonical Evidence-input declarations at build time. */ + readonly evidenceReferenceAlternatives: readonly string[]; readonly prompts: readonly (Prompt & { readonly parameters: Readonly>; })[]; diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index fb08731dc8..b50f4bd547 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -1,6 +1,7 @@ import { RUST_BINARY_PROVIDER_IDENTITY } from "./RustBinaryProviderIdentity.js"; import { projectStableArtifactFailure, + stableArtifactInputRecovery, projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; @@ -161,12 +162,16 @@ const outputLimit = (path: string) => fixedAllowanceOptions("The Rust complete-report allowance", "report"), ); +/** Resource-guard guidance delivered with every Rust inspection report. */ +export const RUST_RESOURCE_GUARD_LIMITATION = + "Resource guards bound the selected file to 256 MiB, the compressed dependency stream to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the consumed compressed-stream length, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation."; + const limitationsFor = ( image: ReturnType, ): string[] => [ "Embedded dependency metadata is file content. It does not authenticate package names, versions, sources, or the compiler that produced the binary.", "Only the .dep-v0 dependency section and read-only compiler path strings are inspected. Functions, types, and runtime behavior are not recovered.", - "Resource guards bound the selected file to 256 MiB, a dependency section to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the section size, and the complete report to 8 MiB. Package and dependency counts are admitted against that output budget before schema cloning, graph objects, or reverse-edge allocation.", + RUST_RESOURCE_GUARD_LIMITATION, ...(image.section_names_limitation === undefined ? [] : [image.section_names_limitation]), @@ -340,9 +345,11 @@ const artifactReaderFailure = ( }, ); if (cause.reason === "path") - return new AnalysisInputError(OPERATION, { cause }, [ - { path: ["path"], reason: "invalid_format", message: cause.message }, - ]); + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [{ path: ["path"], reason: "invalid_format", message: cause.message }], + ); return new ProviderAdapterError(RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, { cause, diagnostics: { path, reason: cause.message }, diff --git a/src/server/DeferredEvidenceMcpServer.test.ts b/src/server/DeferredEvidenceMcpServer.test.ts index 6094693ff1..425ac88557 100644 --- a/src/server/DeferredEvidenceMcpServer.test.ts +++ b/src/server/DeferredEvidenceMcpServer.test.ts @@ -51,6 +51,7 @@ const catalog: RecordedMcpCatalog = { outputSchema: z.toJSONSchema(output), }, ], + evidenceReferenceAlternatives: [], prompts: [ { name: "explain", @@ -78,6 +79,7 @@ const fixture = async ( readonly invalidOutput?: boolean; readonly failAfterTool?: boolean; readonly maximumInputElements?: number; + readonly evidenceReferenceAlternative?: boolean; } = {}, ) => { const started = barrier(); @@ -85,7 +87,9 @@ const fixture = async ( const closed = vi.fn(); let loads = 0; const server = new DeferredEvidenceMcpServer( - catalog, + options.evidenceReferenceAlternative + ? { ...catalog, evidenceReferenceAlternatives: ["fixture"] } + : catalog, new ToolResultDelivery(1024 * 1024), async (target) => { loads++; @@ -188,6 +192,26 @@ it("refuses excess input before loading a deferred runtime, then admits a valid expect(f.calls).toHaveBeenCalledOnce(); }); +it("uses recorded Evidence-reference guidance without loading the deferred runtime", async () => { + const f = await fixture({ + maximumInputElements: 2, + evidenceReferenceAlternative: true, + }); + const result = await f.client.callTool({ + name: "fixture", + arguments: { n: 3, other: [0] }, + }); + const text = result.content.find((part) => part.type === "text")?.text; + expect(JSON.parse(text ?? "null")).toMatchObject({ + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, + }); + expect(f.loads()).toBe(0); + expect(f.calls).not.toHaveBeenCalled(); +}); + it("discovers without initialization, then shares one canonical runtime and preserves request metadata", async () => { const hold = barrier(); const f = await fixture({ hold }); diff --git a/src/server/DeferredEvidenceMcpServer.ts b/src/server/DeferredEvidenceMcpServer.ts index 31361fef69..6356a58d70 100644 --- a/src/server/DeferredEvidenceMcpServer.ts +++ b/src/server/DeferredEvidenceMcpServer.ts @@ -78,6 +78,8 @@ export class DeferredEvidenceMcpServer extends EvidenceMcpServerCore { "full", catalog.identity, ); + for (const name of catalog.evidenceReferenceAlternatives) + this.registerEvidenceReferenceAlternative(name); for (const tool of catalog.tools) { const { name, inputSchema, outputSchema, ...metadata } = tool; let binding: DeferredTool; diff --git a/src/server/EvidenceMcpServerCore.ts b/src/server/EvidenceMcpServerCore.ts index 80600d4ace..accf187fa8 100644 --- a/src/server/EvidenceMcpServerCore.ts +++ b/src/server/EvidenceMcpServerCore.ts @@ -62,6 +62,7 @@ import { } from "./toolResultEncoding.js"; import { RequestTrackingMcpServer } from "./RequestTrackingMcpServer.js"; import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { canReferenceInlineEvidence } from "./evidenceInputRegistration.js"; import { currentMcpToolRequest, McpToolRequest, @@ -344,6 +345,11 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { | CompactInputSchemaPresentation | undefined; private readonly maximumInputElements: number; + private readonly evidenceReferenceAlternatives = new Set(); + + protected registerEvidenceReferenceAlternative(name: string): void { + this.evidenceReferenceAlternatives.add(name); + } constructor( info: Implementation, @@ -392,6 +398,8 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { }, callback: ToolCallback, ): RegisteredTool { + if (canReferenceInlineEvidence(config.inputSchema)) + this.registerEvidenceReferenceAlternative(name); const budget = this.compactPresentation?.budgetBytes; const inputSchema = budget !== undefined && @@ -467,6 +475,7 @@ export class EvidenceMcpServerCore extends RequestTrackingMcpServer { () => this.withAdmittedAnalysis, this.outputSchemaProfile, this.maximumInputElements, + this.evidenceReferenceAlternatives, ), ); } @@ -505,6 +514,7 @@ class ErrorEvidenceTransport implements Transport { private readonly currentAdmission: () => WithAdmittedAnalysis | undefined, private readonly outputSchemaProfile: McpOutputSchemaProfile, private readonly maximumInputElements: number, + private readonly evidenceReferenceAlternatives: ReadonlySet, ) { if (transport.hasPerRequestStream !== undefined) this.hasPerRequestStream = transport.hasPerRequestStream; @@ -968,8 +978,9 @@ class ErrorEvidenceTransport implements Transport { "The MCP tool arguments exceed the configured input element allowance; schema validation and tool execution did not start.", { max_input_elements: this.maximumInputElements }, { - remediationAction: - "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", + remediationAction: this.evidenceReferenceAlternatives.has(tool) + ? 'If the source Evidence is retained, use {"kind":"retained-evidence","evidence_id":"ev_..."} for declared Evidence fields; use a summary\'s parent_evidence_id for its complete source. Otherwise import the originating Evidence bundle when available. Reduce the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance.' + : "Reduce the combined array elements and object members in the arguments, or restart REA with a larger REA_MCP_MAX_INPUT_ELEMENTS allowance for this workload.", }, ); const result = this.delivery.toErrorToolResult(error, targetContent); diff --git a/src/server/ToolResultDelivery.ts b/src/server/ToolResultDelivery.ts index f16ae5b30a..284aec3cb6 100644 --- a/src/server/ToolResultDelivery.ts +++ b/src/server/ToolResultDelivery.ts @@ -10,12 +10,14 @@ import { type AnalysisErrorProjection, } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { NoBinaryOpenError } from "../domain/configurationErrors.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { MCP_TRANSPORT_EVIDENCE_DISCOVERY, MCP_TRANSPORT_EVIDENCE_EXPORT, + MCP_TRANSPORT_EVIDENCE_LINKS, MCP_TRANSPORT_EVIDENCE_RECORDS, } from "../domain/analysisErrorPresentation.js"; import { @@ -85,6 +87,8 @@ const evidenceBundleOverflowStep = (value: JsonValue): string => { // A summary already used detail: summary; export writes outside one frame. if (projection === "evidence-bundle-summary") return MCP_TRANSPORT_EVIDENCE_EXPORT; + if (projection === "evidence-link-inspection") + return MCP_TRANSPORT_EVIDENCE_LINKS; return projection === "evidence-record-selection" ? MCP_TRANSPORT_EVIDENCE_RECORDS : MCP_TRANSPORT_EVIDENCE_DISCOVERY; @@ -101,12 +105,22 @@ export class ToolResultDelivery { selected?: JsonValue, viewSource?: JsonValue, ) => Readonly> = () => ({}), + private readonly noTargetRecovery?: string, ) { this.maximumResponseBytes = maximumResponseBytes; this.resultBudgetBytes = maximumResponseBytes - 1024; Object.freeze(this); } + /** Bind scope-specific recovery without mutating a shared delivery policy. */ + withNoTargetRecovery(recovery: string): ToolResultDelivery { + return new ToolResultDelivery( + this.maximumResponseBytes, + this.nativeContinuation, + recovery, + ); + } + /** Serialize a plain application result or its actionable error. */ toCallToolResult( result: Result, @@ -180,11 +194,26 @@ export class ToolResultDelivery { toErrorToolResult( error: AnalysisError, targetContent: boolean, + details?: Readonly>, ): CallToolResult { - return this.projectedErrorResult( - projectAnalysisError(error), - targetContent, - ); + const base = projectAnalysisError(error); + const projected = + details === undefined + ? base + : { ...base, details: { ...base.details, ...details } }; + if ( + error instanceof NoBinaryOpenError && + this.noTargetRecovery !== undefined + ) + return this.projectedErrorResult( + { + ...projected, + message: this.noTargetRecovery, + remediation: { action: this.noTargetRecovery }, + }, + targetContent, + ); + return this.projectedErrorResult(projected, targetContent); } /** Retain valid partial Evidence and reference it only after recording succeeds. */ diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 2f82c99c99..79649fcd3e 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -3,6 +3,7 @@ import { type CapabilityServiceOptions, } from "../composition/capabilityServices.js"; import { registerToolFamilies } from "../contracts/toolFamilyIds.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "./recoveryInstructions.js"; import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { parseMcpInputBudget } from "../config/mcpInputBudget.js"; import { registerAnalysisSettingsTools } from "./registerAnalysisSettingsTools.js"; @@ -13,6 +14,7 @@ import { registerCaptureTools } from "./registerCaptureTools.js"; import { registerSignalTools } from "./registerSignalTools.js"; import { registerWasmTools } from "./registerWasmTools.js"; import { registerApkSignatureTools } from "./registerApkSignatureTools.js"; +import { retainedDebuggerDiagnostics } from "../process/ownedDebuggerCleanupError.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { captureProviderEnvironment } from "./providerEnvironment.js"; import { createByteEntropyService } from "../composition/binaryDiagnostics.js"; @@ -174,7 +176,13 @@ export const createServer = ( options.providerEnvironment, environment, ); - const delivery = selectToolResultDelivery(environment, options.delivery); + const baseDelivery = selectToolResultDelivery(environment, options.delivery); + const delivery = + session !== undefined && + advertisedToolFamilies !== undefined && + !advertisedToolFamilies.has("session") + ? baseDelivery.withNoTargetRecovery(SCOPED_NO_TARGET_RECOVERY) + : baseDelivery; const capabilities = createCapabilityServices({ environment, providerEnvironment, @@ -307,7 +315,10 @@ export const createServer = ( previousOnclose?.(); void closeProviders().catch((cause: unknown) => { logger.error( - { error: cause instanceof Error ? cause.message : String(cause) }, + { + error: cause instanceof Error ? cause.message : String(cause), + ...retainedDebuggerDiagnostics(cause), + }, "Provider cleanup failed during server shutdown", ); }); diff --git a/src/server/evidenceInputRegistration.ts b/src/server/evidenceInputRegistration.ts new file mode 100644 index 0000000000..c46c1ed15b --- /dev/null +++ b/src/server/evidenceInputRegistration.ts @@ -0,0 +1,10 @@ +/** Internal registration facts derived from canonical Evidence input contracts. */ +const evidenceInputs = new WeakSet(); + +export const markEvidenceInput = (schema: T): T => { + evidenceInputs.add(schema); + return schema; +}; + +export const canReferenceInlineEvidence = (schema: unknown): boolean => + typeof schema === "object" && schema !== null && evidenceInputs.has(schema); diff --git a/src/server/recoveryInstructions.ts b/src/server/recoveryInstructions.ts index 60483ea373..ee64745c24 100644 --- a/src/server/recoveryInstructions.ts +++ b/src/server/recoveryInstructions.ts @@ -16,3 +16,7 @@ export const recoveryInstructions = ( runtimeSettingsAvailable: boolean, ): string => `${RECOVERY_START}${runtimeSettingsAvailable ? RUNTIME_SETTINGS_RECOVERY : LAUNCH_SETTINGS_RECOVERY}${EXPLICIT_ARGUMENT_RECOVERY}${RECOVERY_END}`; + +/** Recovery when a scoped session cannot advertise the target lifecycle. */ +export const SCOPED_NO_TARGET_RECOVERY = + "No target is open, and this MCP connection does not advertise open_binary. Preserve needed outputs before restarting; retained in-memory Evidence and Unknown history will not carry over. Add session to REA_MCP_TOOL_FAMILIES in the environment that starts REA and restart the server; then call open_binary with the chosen local path."; diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts index 92c73bd2c5..116a9c07b6 100644 --- a/src/server/registerEnhancedTools.ts +++ b/src/server/registerEnhancedTools.ts @@ -207,8 +207,9 @@ export const registerEnhancedTools = ( server.registerTool( traceFeature.name, toolRegistrationOptions(traceFeature), - (input, context) => + ({ detail, ...input }, context) => executeEnhancedTool(registration, traceFeature, { + detail, validatedCall: { name: "trace_feature", input }, context, }), @@ -292,7 +293,12 @@ const executeEnhancedTool = async ( registration.recordEvidence, result, ); - await reportOperationEnd(progress, name, result.ok); + const finish = async ( + response: CallToolResult, + ): Promise => { + await reportOperationEnd(progress, name, response.isError !== true); + return response; + }; if (result.ok) { const evidence = createWorkflowEvidence({ target: registration.activeTarget?.(), @@ -307,7 +313,9 @@ const executeEnhancedTool = async ( }); const recorded = registration.recordEvidence?.(evidence); if (recorded !== undefined && !recorded.ok) - return registration.delivery.toCallToolResult(recorded, contract); + return finish( + registration.delivery.toCallToolResult(recorded, contract), + ); if ( name !== "trace_native_ui_action" && registration.allowsSnapshotReplay?.(name) === true @@ -322,7 +330,9 @@ const executeEnhancedTool = async ( const snapshot = registration.recordWorkflowSnapshot?.(workflowRecord); if (snapshot !== undefined && !snapshot.ok) - return registration.delivery.toCallToolResult(snapshot, contract); + return finish( + registration.delivery.toCallToolResult(snapshot, contract), + ); } } const unknowns = recordWorkflowUnknowns({ @@ -330,9 +340,12 @@ const executeEnhancedTool = async ( result: result.value.result, evidenceId: evidence.evidence_id, recordUnknown: registration.recordUnknown, + evidenceRetained: recorded?.ok === true, }); if (!unknowns.ok) - return registration.delivery.toCallToolResult(unknowns, contract); + return finish( + registration.delivery.toCallToolResult(unknowns, contract), + ); const delivered = request.detail === "summary" ? summarizeRetainedAnalysis(evidence, { @@ -341,16 +354,22 @@ const executeEnhancedTool = async ( }) : ok(evidence); if (!delivered.ok) - return registration.delivery.toCallToolResult(delivered, contract); - return registration.delivery.toEvidenceToolResult( - delivered.value, - contract, - request.detail === "summary" - ? registration.recordEvidence?.(delivered.value) - : recorded, + return finish( + registration.delivery.toCallToolResult(delivered, contract), + ); + return finish( + registration.delivery.toEvidenceToolResult( + delivered.value, + contract, + request.detail === "summary" + ? registration.recordEvidence?.(delivered.value) + : recorded, + ), ); } - return deliverEnhancedFailure(registration, contract, result.error); + return finish( + deliverEnhancedFailure(registration, contract, result.error), + ); }, ); return admitted.ok diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index e07389528f..5f5bd141cb 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -237,7 +237,13 @@ const registerOpenLifecycleTool = ({ }, openContract, ) - : server.delivery.toCallToolResult(opened, openContract); + : server.delivery.toErrorToolResult( + opened.error, + openContract.returnsTargetContent, + { + selection_at_failure: opened.selection, + }, + ); }, ); }; diff --git a/src/server/toolLogging.ts b/src/server/toolLogging.ts index 36513785c1..7ea27dcf40 100644 --- a/src/server/toolLogging.ts +++ b/src/server/toolLogging.ts @@ -2,11 +2,13 @@ import type { Result } from "../domain/result.js"; import type { Logger } from "pino"; /** Log one MCP tool's duration and status without recording caller input. */ -export const logToolExecution = async ( +export const logToolExecution = async < + Outcome extends Result, +>( logger: Logger, tool: string, - execute: () => Promise>, -): Promise> => { + execute: () => Promise, +): Promise => { const startedAt = performance.now(); const result = await execute(); logger[result.ok ? "info" : "warn"]( diff --git a/src/server/toolRegistrationOptions.ts b/src/server/toolRegistrationOptions.ts index 45723cef17..4062796304 100644 --- a/src/server/toolRegistrationOptions.ts +++ b/src/server/toolRegistrationOptions.ts @@ -9,6 +9,10 @@ import { toolInputSchemaWithMetadata, toolOutputSchemaWithMetadata, } from "../contracts/toolSchemaMetadata.js"; +import { + canReferenceInlineEvidence, + markEvidenceInput, +} from "./evidenceInputRegistration.js"; /** Project the canonical Zod contracts directly into SDK registration. */ export const toolRegistrationOptions = ( @@ -40,7 +44,9 @@ export const toolRegistrationOptions = ( return { title: contract.title, description: contract.description, - inputSchema, + inputSchema: evidenceInputs.hasEvidenceReferenceAlternative + ? markEvidenceInput(inputSchema) + : inputSchema, outputSchema: toolOutputSchemaWithMetadata(contract), annotations: contract.annotations, }; @@ -60,5 +66,10 @@ export const applicationToolRegistrationOptions = < validate: (value: unknown) => ({ value }), }, }; - return { ...registration, inputSchema }; + return { + ...registration, + inputSchema: canReferenceInlineEvidence(registration.inputSchema) + ? markEvidenceInput(inputSchema) + : inputSchema, + }; }; diff --git a/src/sqlite/SqliteDatabaseFailures.ts b/src/sqlite/SqliteDatabaseFailures.ts index e049b61318..0bf8620e58 100644 --- a/src/sqlite/SqliteDatabaseFailures.ts +++ b/src/sqlite/SqliteDatabaseFailures.ts @@ -1,4 +1,5 @@ import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { ArtifactFileCleanupFailure, bindArtifactFileCleanup, @@ -172,13 +173,17 @@ export const sqliteDatabaseFailure = ( return new AnalysisCancelledError(operation, { cause: artifactFileCancellationCause(cause), }); - return new AnalysisInputError(operation, { cause }, [ - { - path: ["path"], - reason: "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + operation, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); } if (cause instanceof Error && cause.name === "AbortError") return new AnalysisCancelledError(operation, { diff --git a/src/sqlite/SqliteDatabaseSnapshot.ts b/src/sqlite/SqliteDatabaseSnapshot.ts index 79daeec560..b36763829a 100644 --- a/src/sqlite/SqliteDatabaseSnapshot.ts +++ b/src/sqlite/SqliteDatabaseSnapshot.ts @@ -1,4 +1,4 @@ -import { lstat, open, writeFile } from "node:fs/promises"; +import { lstat, open, realpath, writeFile } from "node:fs/promises"; import { constants, type BigIntStats } from "node:fs"; import { join } from "node:path"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; @@ -24,6 +24,7 @@ import { SQLITE_DATABASE_LIMITS } from "./SqliteDatabaseLimits.js"; const fileSystem: StableArtifactFileSystem = { lstat: (path) => lstat(path, { bigint: true }), open, + realpath, }; type SnapshotMemberRole = "database" | "wal"; @@ -165,14 +166,17 @@ const duringAcquisition = async ( }; const readObservedArtifact = ( + role: SnapshotMemberRole, path: string, observed: BigIntStats, maximumBytes: number, selectedFileSystem: StableArtifactFileSystem, resourceScope: ArtifactResourceScope, signal?: AbortSignal, -) => - duringAcquisition(path, () => +) => { + const resolveSelectedPath = + role === "database" ? selectedFileSystem.realpath : undefined; + return duringAcquisition(path, () => readStableArtifact( path, maximumBytes, @@ -180,9 +184,15 @@ const readObservedArtifact = ( { open: (selectedPath, flags) => selectedFileSystem.open(selectedPath, flags), + ...(resolveSelectedPath === undefined + ? {} + : { + realpath: (selectedPath: string) => + resolveSelectedPath.call(selectedFileSystem, selectedPath), + }), lstat: async (selectedPath) => { const current = await selectedFileSystem.lstat(selectedPath); - if (!sameState(observed, current)) + if (selectedPath === path && !sameState(observed, current)) throw new ArtifactReaderFailure( "integrity", `SQLite snapshot file set changed during acquisition: ${selectedPath}`, @@ -193,6 +203,7 @@ const readObservedArtifact = ( resourceScope, ), ); +}; const verifyInactiveJournal = async ( path: string, @@ -301,6 +312,7 @@ export const captureSqliteDatabaseSnapshot = async ( return recordMember( role, await readObservedArtifact( + role, role === "database" ? path : walPath, observed, maximumBytes, diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 27ba86a1bc..d7bc63aaed 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -1,5 +1,6 @@ import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { stableArtifactInputRecovery } from "../artifacts/StableArtifactFailure.js"; import { artifactFileCancellationCause, bindArtifactFileCleanup, @@ -307,7 +308,13 @@ const classifyWabtFailure = ( if (phase === "artifact") return new AnalysisInputError( "inspect_wasm_artifact", - { cause, ...artifactOptions }, + { + cause, + ...artifactOptions, + ...(cause instanceof ArtifactReaderFailure + ? stableArtifactInputRecovery(cause) + : {}), + }, [ { path: [...(execution?.inputField ?? ["path"])], diff --git a/tests/acceptance/applications/artifactComparisonCoverage.test.ts b/tests/acceptance/applications/artifactComparisonCoverage.test.ts index e84138a9a8..8b22fc310c 100644 --- a/tests/acceptance/applications/artifactComparisonCoverage.test.ts +++ b/tests/acceptance/applications/artifactComparisonCoverage.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, symlink, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; @@ -9,7 +9,6 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../../src/domain/artifactGraph.js"; -import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; import { artifactIdForContent } from "../../../src/domain/artifactIdentity.js"; import { createEvidence, @@ -17,6 +16,10 @@ import { type Evidence, } from "../../../src/domain/evidence.js"; +import { + connectArtifactComparisonMcp, + inventoryEvidence, +} from "../../fixtures/artifactComparisonMcp.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -25,17 +28,6 @@ import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; const plist = (message: string) => `\nmessage${message}\n`; -const inventoryEvidence = (input: unknown): Evidence => { - const inspection = parseEvidence(input); - const inventory = artifactInspectionResultSchema - .parse(inspection.normalized_result) - .substeps.find( - ({ evidence }) => evidence.operation === "inventory_artifact", - )?.evidence; - if (inventory === undefined) throw new Error("Expected inventory Evidence"); - return parseEvidence(inventory); -}; - const inspectPair = async ( cli: TestCli, leftPath: string, @@ -46,12 +38,8 @@ const inspectPair = async ( }); expect(result.exitCode, `${result.stderr}\n${result.stdout}`).toBe(0); const left = inventoryEvidence(result.json); - const { call } = await connectLocalToolsMcp(); - const opened = await call("open_binary", { path: rightPath }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const inspected = await call("inspect_artifact", {}); - expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); - return { left, right: inventoryEvidence(inspected.structuredContent), call }; + const { call, inspect } = await connectArtifactComparisonMcp(); + return { left, right: await inspect(rightPath), call }; }; const assertCoverageControls = async ({ @@ -360,14 +348,7 @@ cliTest( }); expect(leftResult.exitCode, leftResult.stderr).toBe(0); const left = inventoryEvidence(leftResult.json); - const { call } = await connectLocalToolsMcp(); - const inspect = async (path: string) => { - const opened = await call("open_binary", { path }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const result = await call("inspect_artifact", {}); - expect(result.isError, JSON.stringify(result)).not.toBe(true); - return inventoryEvidence(result.structuredContent); - }; + const { call, inspect } = await connectArtifactComparisonMcp(); const compare = async (left: Evidence, right: Evidence) => { const result = await call("compare_artifacts", { left, right }); expect(result.isError, JSON.stringify(result)).not.toBe(true); @@ -466,12 +447,8 @@ cliTest.skipIf(process.platform === "win32")( }); expect(result.exitCode, result.stderr).toBe(0); const left = inventoryEvidence(result.json); - const { call } = await connectLocalToolsMcp(); - const opened = await call("open_binary", { path: rightPath }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const inspected = await call("inspect_artifact", {}); - expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); - const right = inventoryEvidence(inspected.structuredContent); + const { call, inspect } = await connectArtifactComparisonMcp(); + const right = await inspect(rightPath); expect(right.subject?.digest.sha256).toBe(left.subject?.digest.sha256); const comparison = await call("compare_artifacts", { left, right }); expect(comparison.isError, JSON.stringify(comparison)).not.toBe(true); @@ -491,6 +468,58 @@ cliTest.skipIf(process.platform === "win32")( 30_000, ); +cliTest.skipIf(process.platform === "win32")( + "reports a directory file that becomes a symlink without marking the link executable", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-directory-symlink-"); + const leftPath = join(root, "l.app"), + rightPath = join(root, "r.app"); + await Promise.all([mkdir(leftPath), mkdir(rightPath)]); + await Promise.all( + [leftPath, rightPath].flatMap((path) => [ + writeFile( + join(path, "Info.plist"), + "CFBundleExecutablemain", + ), + writeFile(join(path, "main"), machoImage({})), + writeFile(join(path, "target.txt"), "target"), + ]), + ); + // The file holds the bytes the link stores: its target path. + await writeFile(join(leftPath, "current"), "target.txt"); + await chmod(join(leftPath, "current"), 0o644); + await symlink("target.txt", join(rightPath, "current")); + const { left, right, call } = await inspectPair(cli, leftPath, rightPath); + const current = (evidence: Evidence) => + artifactInventoryResultSchema + .parse(evidence.normalized_result) + .occurrences.find(({ logical_path }) => logical_path === "current"); + expect(current(left)).toMatchObject({ + entry_kind: "file", + executable: false, + }); + // The host gives the link permission bits of its own; they mark nothing. + expect(current(right)).toMatchObject({ + entry_kind: "symlink", + executable: false, + }); + const compared = await call("compare_artifacts", { left, right }); + expect(compared.isError, JSON.stringify(compared)).not.toBe(true); + expect( + parseEvidence(compared.structuredContent).normalized_result, + ).toMatchObject({ + changes: expect.arrayContaining([ + expect.objectContaining({ + logical_path: "current", + classification: "unknown", + dimensions: ["metadata", "availability"], + }), + ]), + }); + }, + 30_000, +); + cliTest.skipIf(process.platform === "win32")( "retains traversal uncertainty when a directory omits special filesystem entries", async ({ cli, processes }) => { diff --git a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts index f9aa6a7532..a0df8e8c2c 100644 --- a/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts +++ b/tests/acceptance/applications/bundleResourceRootCliMcp.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { build } from "plist"; import { lstat, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -216,11 +217,13 @@ cliTest.skipIf(process.platform !== "darwin")( const retained = await call("get_evidence_bundle", {}); expect(retained.isError).not.toBe(true); - const evidenceBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent); + const evidenceBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); for (const operation of resourceOperations) { - const record = evidenceBundle.result.records.find( + const record = evidenceBundle.records.find( (record) => record.operation === operation, ); expectResourceEvidence(record, operation, firstBundle); diff --git a/tests/acceptance/applications/electronProtocolHandleCli.test.ts b/tests/acceptance/applications/electronProtocolHandleCli.test.ts new file mode 100644 index 0000000000..42861abc14 --- /dev/null +++ b/tests/acceptance/applications/electronProtocolHandleCli.test.ts @@ -0,0 +1,90 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains wrapped protocol.handle source registrations through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-handle-presence-"); + await writeFile( + join(root, "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "main.cjs"), + ` + const { protocol, session } = require("electron"); + protocol.handle("module-omitted"); + protocol.handle("module-null", null); + protocol.handle("module-undefined", void 0); + session.defaultSession.protocol.handle("session-omitted"); + session.defaultSession.protocol.handle("session-null", null); + session.defaultSession.protocol.handle("session-undefined", void 0); + protocol.registerStringProtocol("legacy-omitted"); + protocol.registerStringProtocol("legacy-null", null); + protocol.registerStringProtocol("legacy-undefined", void 0); + throw new Error("analysis fixture must remain inert"); + `, + ); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + expect(result.summary.protocol_registrations).toBe(6); + const schemes = result.graph.nodes.filter( + ({ kind }) => kind === "url-scheme", + ); + expect( + schemes.map((node) => node.observations[0]?.label).toSorted(), + ).toEqual([ + "module-null", + "module-omitted", + "module-undefined", + "session-null", + "session-omitted", + "session-undefined", + ]); + for (const scheme of schemes) { + const observation = scheme.observations[0]; + expect(observation?.properties).toMatchObject({ + api: "handle", + role: "handler", + resolution: "literal", + session: observation?.label?.startsWith("session-") + ? "default-session" + : "default-protocol", + }); + expect(observation?.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + limitations: expect.arrayContaining([ + expect.stringContaining("successful registration"), + ]), + }); + } + expect( + result.graph.edges.filter((edge) => edge.relation === "handles"), + ).toHaveLength(6); + }, +); diff --git a/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts b/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts index 5e612caf9e..51935ae178 100644 --- a/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts +++ b/tests/acceptance/applications/javascriptCheckpointReserveCli.test.ts @@ -170,3 +170,74 @@ cliTest.skipIf(process.versions.bun !== undefined).for([ expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu); }, ); + +cliTest.skipIf(process.versions.bun !== undefined)( + "keeps every source's static facts after semantic results use the parent result budget", + { timeout: 90_000 }, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-static-capacity-"); + // Plain functions produce semantic graphs large enough to use the result + // budget of this heap. The later sources' storage findings then produce + // static checkpoints that exceed its reserve for the next checkpoint. + const vendors = Array.from({ length: 12 }, (_, vendor) => [ + `a-vendor-${String(vendor)}.mjs`, + Array.from( + { length: 100 }, + (_, index) => + `export function v${String(vendor)}_${String(index)}(input) { const value = input + ${String(index)}; const pair = { value }; return pair.value; }`, + ).join("\n"), + ]); + const callsPerSource = 12_000; + const sources = Array.from({ length: 5 }, (_, source) => [ + `z-source-${String(source)}.mjs`, + Array.from( + { length: callsPerSource }, + (_, index) => + `localStorage.getItem("k${String(source)}_${String(index)}");`, + ).join("\n"), + ]); + for (const [name, text] of [...vendors, ...sources]) + await writeFile(join(root, name!), text!); + const response = await cli.run({ + arguments: ["analyze", root, "--format", "json"], + environment: { + NODE_OPTIONS: "--max-old-space-size=640 --max-semi-space-size=8", + }, + timeoutMs: 75_000, + }); + expect(response.stderr).not.toMatch(/FATAL ERROR:.*heap/iu); + expect( + response.exitCode, + JSON.stringify(response.json)?.slice(0, 2000), + ).toBe(0); + const result = javascriptApplicationAnalysisResultSchema.parse( + parseEvidence(response.json).normalized_result, + ); + expect(result.statistics).toMatchObject({ + parsed_javascript_files: vendors.length + sources.length, + skipped_javascript_files: 0, + parse_failures: 0, + }); + // The omission is reported where a reader finds the retained facts. + for (const [name, text] of sources) { + const digest = createHash("sha256").update(text!).digest("hex"); + const node = result.graph.nodes.find( + ({ kind, identity }) => + kind === "javascript-asset" && + identity.strategy === "content-digest" && + identity.sha256 === digest, + ); + const observation = node?.observations.find( + ({ properties }) => + properties.application_projection_failure !== undefined, + ); + expect(observation?.label, name).toBe(name); + expect( + z + .object({ storage: z.array(z.unknown()) }) + .parse(observation?.properties.static_analysis).storage, + ).toHaveLength(callsPerSource); + } + expect(result.graph.coverage.status).toBe("partial"); + }, +); diff --git a/tests/acceptance/applications/javascriptEventPayload.test.ts b/tests/acceptance/applications/javascriptEventPayload.test.ts index c4689250de..fbe3a9422a 100644 --- a/tests/acceptance/applications/javascriptEventPayload.test.ts +++ b/tests/acceptance/applications/javascriptEventPayload.test.ts @@ -162,3 +162,64 @@ cliTest( }, 120_000, ); + +cliTest( + "traces a retained empty literal endpoint by its preserved value", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-empty-endpoint-seed-"); + await writeFile( + join(root, "main.js"), + 'export const load = async () => (await fetch("")).json();\n', + ); + const analyzed = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--json", + ], + timeoutMs: 60_000, + }); + expect(analyzed.exitCode, analyzed.stderr).toBe(0); + const application = toolContract( + "analyze_javascript_application", + ).outputSchema.parse(analyzed.json); + const graph = javascriptApplicationAnalysisResultSchema.parse( + application.normalized_result, + ).semantic_graph; + // Analysis keeps the complete literal endpoint, here the empty string. + const retained = new Set( + graph.nodes + .filter( + (node) => + (node.kind === "request" || node.kind === "response") && + node.properties.endpoint === "", + ) + .map((node) => node.node_id), + ); + expect(retained.size).toBeGreaterThan(0); + const request = join(root, "endpoint-query.json"); + await writeFile( + request, + JSON.stringify({ + application, + query: { + seed: { kind: "endpoint", value: "" }, + direction: "forward-influence", + }, + }), + ); + const traced = await cli.run({ + arguments: ["trace-javascript-semantics", request, "--json"], + timeoutMs: 60_000, + }); + expect(traced.exitCode, traced.stdout).toBe(0); + const trace = javaScriptSemanticTraceResultSchema.parse( + toolContract("trace_javascript_semantics").outputSchema.parse(traced.json) + .normalized_result, + ); + expect(new Set(trace.seed_node_ids)).toEqual(retained); + }, + 120_000, +); diff --git a/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts new file mode 100644 index 0000000000..357bc04959 --- /dev/null +++ b/tests/acceptance/applications/javascriptNodeTargetReconciliationCli.test.ts @@ -0,0 +1,52 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "reconciles synthetic Node target entry Evidence through %s", + async (surface, { cli }) => { + const input = nodeReconciliationFixture(false); + let document: unknown; + if (surface === "CLI") { + const root = await createTestTempDirectory( + "rea-node-reconciliation-cli-", + ); + const path = join(root, "input.json"); + await writeFile(path, JSON.stringify(input)); + const response = await cli.run({ + arguments: ["reconcile-javascript-runtime", path, "--format", "json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const call = await cli.sharedMcpCall!(); + const response = await call("reconcile_javascript_runtime", input); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = javascriptRuntimeReconciliationResultSchema.parse( + (document as { normalized_result: unknown }).normalized_result, + ); + expect(result.reconciliations).toEqual([ + expect.objectContaining({ + entity_kind: "target", + status: "matched", + reason: "unique-location-match", + confidence: "medium", + candidate_static_count: 1, + }), + ]); + expect(result.summary).toMatchObject({ + runtime_scripts: 0, + static_loaded: 0, + static_not_observed: 0, + }); + expect( + result.graph.edges.filter(({ relation }) => relation === "observed_as"), + ).toHaveLength(1); + }, +); diff --git a/tests/acceptance/applications/mobileDirectoryComponents.test.ts b/tests/acceptance/applications/mobileDirectoryComponents.test.ts index 57bbb233f0..ed39edbfe1 100644 --- a/tests/acceptance/applications/mobileDirectoryComponents.test.ts +++ b/tests/acceptance/applications/mobileDirectoryComponents.test.ts @@ -260,6 +260,7 @@ cliTest.for(fixtures)( inventory_complete: true, }); if ("packaging_model" in projection) { + expect(projection.unclassified_components, mode).toEqual([]); expect(projection.packaging_model, mode).toBe( mode === "files" ? "stage" : "unknown", ); diff --git a/tests/acceptance/artifactComparisonAsarUnpacked.test.ts b/tests/acceptance/artifactComparisonAsarUnpacked.test.ts index 476632caa9..19992608a7 100644 --- a/tests/acceptance/artifactComparisonAsarUnpacked.test.ts +++ b/tests/acceptance/artifactComparisonAsarUnpacked.test.ts @@ -8,19 +8,13 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../src/domain/artifactGraph.js"; -import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js"; -import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { artifactGraphDigest, artifactManifestId, } from "../../src/domain/artifactIdentity.js"; import { parseArtifactInventoryEvidence } from "../../src/domain/artifactInventoryEvidence.js"; -import { - createEvidence, - parseEvidence, - type Evidence, -} from "../../src/domain/evidence.js"; -import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; const historicalInventory = (source: Evidence): Evidence => { @@ -77,28 +71,7 @@ it("compares ASAR companion declarations from public MCP inventories and preserv // This fixture validates advertised SDK-converted input/output schemas and // every structured response against the declared JSON Schema dialect. - const { call } = await connectLocalToolsMcp(); - const inspect = async (path: string): Promise => { - const opened = await call("open_binary", { path }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const inspected = await call("inspect_artifact", {}); - expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); - const inspection = artifactInspectionResultSchema.parse( - parseEvidence(inspected.structuredContent).normalized_result, - ); - const inventory = inspection.substeps.find( - ({ operation }) => operation === "inventory_artifact", - )?.evidence; - if (inventory === undefined) throw new Error("Missing inventory Evidence"); - return parseEvidence(inventory); - }; - const compare = async (left: Evidence, right: Evidence) => { - const response = await call("compare_artifacts", { left, right }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - return artifactComparisonResultSchema.parse( - parseEvidence(response.structuredContent).normalized_result, - ); - }; + const { inspect, compare } = await connectArtifactComparisonMcp(); const packed = await inspect(packedPath); const unpacked = await inspect(unpackedPath); const packedInventory = artifactInventoryResultSchema.parse( diff --git a/tests/acceptance/artifactComparisonEspImage.test.ts b/tests/acceptance/artifactComparisonEspImage.test.ts index bb836b53ea..a6fcfed74e 100644 --- a/tests/acceptance/artifactComparisonEspImage.test.ts +++ b/tests/acceptance/artifactComparisonEspImage.test.ts @@ -16,36 +16,21 @@ import { parseEvidence, type Evidence, } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; it("reports an ESP segment whose load address alone changed", async () => { const root = await createTestTempDirectory("rea-esp-comparison-"); - const { call } = await connectLocalToolsMcp(); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); const inspect = async ( name: string, segments: typeof ESP32C3_APP_SEGMENTS, ): Promise => { const path = join(root, name); await writeFile(path, espImage({ segments })); - const opened = await call("open_binary", { path }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const response = await call("inspect_artifact", {}); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const inventory = artifactInspectionResultSchema - .parse(parseEvidence(response.structuredContent).normalized_result) - .substeps.find( - ({ operation }) => operation === "inventory_artifact", - )?.evidence; - if (inventory === undefined) throw new Error("Missing inventory Evidence"); - return parseEvidence(inventory); - }; - const compare = async (left: Evidence, right: Evidence) => { - const response = await call("compare_artifacts", { left, right }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - return artifactComparisonResultSchema.parse( - parseEvidence(response.structuredContent).normalized_result, - ); + return inspectFile(path); }; const segment = (evidence: Evidence, path: string) => { const occurrence = artifactInventoryResultSchema diff --git a/tests/acceptance/artifactComparisonGodotPck.test.ts b/tests/acceptance/artifactComparisonGodotPck.test.ts new file mode 100644 index 0000000000..e52156f56f --- /dev/null +++ b/tests/acceptance/artifactComparisonGodotPck.test.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; +import { buildGodotPck, type GodotPckMember } from "../fixtures/godotPck.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** Inspect one-member packs and compare their inventories through MCP. */ +const connectPackComparison = async () => { + const root = await createTestTempDirectory("rea-pck-comparison-"); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); + const inspect = async ( + name: string, + packMember: GodotPckMember, + ): Promise => { + const file = join(root, name); + await writeFile(file, buildGodotPck(2, [packMember])); + return inspectFile(file); + }; + return { inspect, compare }; +}; + +const member = (evidence: Evidence) => { + const occurrence = artifactInventoryResultSchema + .parse(evidence.normalized_result) + .occurrences.find(({ logical_path }) => logical_path === "scenes/main.gd"); + if (occurrence === undefined) throw new Error("Missing scenes/main.gd"); + return occurrence; +}; + +it("pairs a Godot PCK member across stored res:// spellings", async () => { + const { inspect, compare } = await connectPackComparison(); + + // Godot 4.3 exports store res:// paths and 4.4.1 exports do not. Godot + // looks both up by the same simplified path, which logical_path holds. + const prefixed = await inspect("prefixed.pck", { + path: "res://scenes/main.gd", + data: "extends Node\n", + }); + const relative = await inspect("relative.pck", { + path: "scenes/main.gd", + data: "extends Node\n", + }); + expect(member(prefixed).format_metadata).toMatchObject({ + source_path: "res://scenes/main.gd", + }); + expect(member(relative).format_metadata).toMatchObject({ + source_path: "scenes/main.gd", + }); + expect(member(relative).artifact_id).toBe(member(prefixed).artifact_id); + + const result = await compare(prefixed, relative); + expect( + result.changes.filter(({ logical_path }) => logical_path !== "."), + ).toEqual([]); + expect(result.summary).toMatchObject({ unchanged: 1, unknown: 0 }); +}); + +it("reports a Godot PCK member that becomes encrypted", async () => { + const { inspect, compare } = await connectPackComparison(); + const script = { path: "res://scenes/main.gd", data: "extends Node\n" }; + const plain = await inspect("plain.pck", script); + const encrypted = await inspect("encrypted.pck", { ...script, flags: 1 }); + // REA does not decrypt members, so the encrypted member has no content. + expect(member(encrypted)).toMatchObject({ + artifact_id: null, + encrypted: true, + hash_status: "unavailable", + }); + + // The encryption change is known; whether the script changed is not. + const result = await compare(plain, encrypted); + expect( + result.changes.filter(({ logical_path }) => logical_path !== "."), + ).toMatchObject([ + { + logical_path: "scenes/main.gd", + classification: "unknown", + dimensions: ["metadata", "availability"], + left_occurrence_id: member(plain).occurrence_id, + right_occurrence_id: member(encrypted).occurrence_id, + }, + ]); +}); + +it("compares encrypted Godot PCK members by their declared MD5", async () => { + const { inspect, compare } = await connectPackComparison(); + // Godot hashes a member's unencrypted bytes, so every export of one script + // declares the same MD5 whatever its random IV. + const encrypted = (name: string, ciphertext: string, script: string) => + inspect(name, { + path: "res://scenes/main.gd", + data: ciphertext, + flags: 1, + md5: createHash("md5").update(script).digest(), + }); + const first = await encrypted("first.pck", "ciphertext A", "extends Node\n"); + const again = await encrypted("again.pck", "ciphertext B", "extends Node\n"); + const edited = await encrypted( + "edited.pck", + "ciphertext C", + "extends Sprite2D\n", + ); + const memberChanges = async (left: Evidence, right: Evidence) => + (await compare(left, right)).changes.filter( + ({ logical_path }) => logical_path !== ".", + ); + + // Equal claims are unverified, so they do not establish equality. + expect(await memberChanges(first, again)).toMatchObject([ + { + logical_path: "scenes/main.gd", + classification: "unknown", + dimensions: ["availability"], + }, + ]); + // An all-zero MD5 is no claim, and one claim alone proves no change. The + // reader notes the all-zero MD5, and that note is the metadata difference. + const unclaimed = await inspect("unclaimed.pck", { + path: "res://scenes/main.gd", + data: "ciphertext D", + flags: 1, + md5: Buffer.alloc(16), + }); + expect(member(unclaimed)).not.toHaveProperty("declared_integrity"); + expect(await memberChanges(first, unclaimed)).toMatchObject([ + { + logical_path: "scenes/main.gd", + classification: "unknown", + dimensions: ["metadata", "availability"], + }, + ]); + // Claims that differ declare different content, but REA cannot verify + // them, so the difference is known and the content change is not. + expect(await memberChanges(first, edited)).toMatchObject([ + { + logical_path: "scenes/main.gd", + classification: "unknown", + dimensions: ["metadata", "availability"], + }, + ]); +}); diff --git a/tests/acceptance/artifactComparisonNodeSea.test.ts b/tests/acceptance/artifactComparisonNodeSea.test.ts index 945e191f6c..6fa8b8d7e1 100644 --- a/tests/acceptance/artifactComparisonNodeSea.test.ts +++ b/tests/acceptance/artifactComparisonNodeSea.test.ts @@ -7,19 +7,14 @@ import { artifactInventoryResultSchema, type ArtifactInventoryResult, } from "../../src/domain/artifactGraph.js"; -import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js"; -import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { FORMAT_DECLARATION_UNKNOWN_LIMITATION } from "../../src/domain/artifactComparison.js"; import { artifactGraphDigest, artifactManifestId, } from "../../src/domain/artifactIdentity.js"; import { parseArtifactInventoryEvidence } from "../../src/domain/artifactInventoryEvidence.js"; -import { - createEvidence, - parseEvidence, - type Evidence, -} from "../../src/domain/evidence.js"; -import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { createEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; const withoutEntryMetadata = (source: Evidence): Evidence => { @@ -72,32 +67,15 @@ it("compares raw SEA asset keys without interpreting serialization order as meta const nfc = "caf\u00e9.txt"; const nfd = "cafe\u0301.txt"; const body = "identical asset bytes"; - const { call } = await connectLocalToolsMcp(); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); const inspect = async ( name: string, assets: readonly { name: string; body: string }[], ): Promise => { const path = join(root, name); await writeFile(path, nodeSeaPe({ fuse: "set", assets })); - const opened = await call("open_binary", { path }); - expect(opened.isError, JSON.stringify(opened)).not.toBe(true); - const response = await call("inspect_artifact", {}); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const inspection = artifactInspectionResultSchema.parse( - parseEvidence(response.structuredContent).normalized_result, - ); - const inventory = inspection.substeps.find( - ({ operation }) => operation === "inventory_artifact", - )?.evidence; - if (inventory === undefined) throw new Error("Missing inventory Evidence"); - return parseEvidence(inventory); - }; - const compare = async (left: Evidence, right: Evidence) => { - const response = await call("compare_artifacts", { left, right }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - return artifactComparisonResultSchema.parse( - parseEvidence(response.structuredContent).normalized_result, - ); + return inspectFile(path); }; const child = (evidence: Evidence) => { const occurrence = artifactInventoryResultSchema @@ -175,6 +153,7 @@ it("compares raw SEA asset keys without interpreting serialization order as meta dimensions: ["availability"], evidence_links: [historical.evidence_id, right.evidence_id], }); + expect(unknown.limitations).toContain(FORMAT_DECLARATION_UNKNOWN_LIMITATION); const reversed = await compare(right, historical); expect( reversed.changes.find(({ logical_path }) => logical_path === nfc), diff --git a/tests/acceptance/artifactComparisonPyInstaller.test.ts b/tests/acceptance/artifactComparisonPyInstaller.test.ts new file mode 100644 index 0000000000..f0c0299ff5 --- /dev/null +++ b/tests/acceptance/artifactComparisonPyInstaller.test.ts @@ -0,0 +1,119 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { + PYTHON_39_MAGIC, + cArchiveExecutable, + pyzArchive, + type CArchiveFixtureMember, +} from "../../src/artifacts/pyinstaller/CArchive.fixture.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** Inspect archives and compare their inventories through MCP. */ +const connectArchiveComparison = async () => { + const root = await createTestTempDirectory("rea-pyinstaller-comparison-"); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); + const inspect = async ( + name: string, + members: readonly CArchiveFixtureMember[], + ): Promise => { + const path = join(root, name); + await writeFile( + path, + cArchiveExecutable({ layout: "2.1", pythonVersion: 309, members }), + ); + return inspectFile(path); + }; + return { inspect, compare }; +}; + +const occurrence = (evidence: Evidence, path: string) => { + const found = artifactInventoryResultSchema + .parse(evidence.normalized_result) + .occurrences.find(({ logical_path }) => logical_path === path); + if (found === undefined) throw new Error(`Missing ${path}`); + return found; +}; + +it("reports a changed PyInstaller declaration whose member bytes are unavailable", async () => { + const { inspect, compare } = await connectArchiveComparison(); + const link = (name: string) => [ + { name, typeCode: "n", payload: Buffer.from("target"), compress: false }, + ]; + + // A rebuild on another filesystem can store the link name decomposed. + // Normalization pairs the links; their stored names stay distinct. + const nfc = "café-link"; + const left = await inspect("nfc", link(nfc)); + const right = await inspect("nfd", link("café-link")); + expect(occurrence(left, nfc)).toMatchObject({ + hash_status: "not-hashed", + format_metadata: { source_name: nfc }, + }); + expect(occurrence(right, nfc)).toMatchObject({ + hash_status: "not-hashed", + format_metadata: { source_name: "café-link" }, + }); + + const result = await compare(left, right); + expect( + result.changes.find(({ logical_path }) => logical_path === nfc), + ).toMatchObject({ + classification: "changed", + dimensions: ["metadata", "availability"], + left_occurrence_id: occurrence(left, nfc).occurrence_id, + right_occurrence_id: occurrence(right, nfc).occurrence_id, + }); +}); + +it("reports a PYZ namespace record that becomes encrypted", async () => { + const { inspect, compare } = await connectArchiveComparison(); + const pyz = { + name: "PYZ.pyz", + typeCode: "z", + compress: false, + payload: pyzArchive({ + magic: PYTHON_39_MAGIC, + modules: [ + { + name: "ns.branch", + typeCode: 3, + body: Buffer.from("legacy namespace payload"), + legacyNamespacePayload: true, + }, + ], + }), + }; + const namespace = "PYZ.pyz/ns/branch"; + const plain = await inspect("plain", [pyz]); + // The legacy key module also encrypts a nonempty namespace payload. + const encrypted = await inspect("encrypted", [ + { + name: "pyimod00_crypto_key", + typeCode: "m", + payload: Buffer.from("key module"), + compress: false, + }, + pyz, + ]); + expect(occurrence(encrypted, namespace)).toMatchObject({ + entry_kind: "metadata", + artifact_id: null, + encrypted: true, + }); + + const result = await compare(plain, encrypted); + expect( + result.changes.find(({ logical_path }) => logical_path === namespace), + ).toMatchObject({ + classification: "changed", + dimensions: ["metadata"], + left_occurrence_id: occurrence(plain, namespace).occurrence_id, + right_occurrence_id: occurrence(encrypted, namespace).occurrence_id, + }); +}); diff --git a/tests/acceptance/artifactComparisonUnityFs.test.ts b/tests/acceptance/artifactComparisonUnityFs.test.ts new file mode 100644 index 0000000000..8e1fe6f034 --- /dev/null +++ b/tests/acceptance/artifactComparisonUnityFs.test.ts @@ -0,0 +1,89 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { buildUnityFs, type UnityFsMember } from "../fixtures/unityfs.js"; + +it("compares UnityFS member declarations without their layout", async () => { + const root = await createTestTempDirectory("rea-unityfs-comparison-"); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); + const inspect = async ( + name: string, + members: readonly UnityFsMember[], + ): Promise => { + const path = join(root, name); + await writeFile(path, buildUnityFs(members)); + return inspectFile(path); + }; + const member = (evidence: Evidence, path: string) => { + const occurrence = artifactInventoryResultSchema + .parse(evidence.normalized_result) + .occurrences.find(({ logical_path }) => logical_path === path); + if (occurrence === undefined) throw new Error(`Missing ${path}`); + return occurrence; + }; + const memberChanges = async (left: Evidence, right: Evidence) => + (await compare(left, right)).changes.filter( + ({ logical_path }) => logical_path !== ".", + ); + + const nfc = "CAB-café"; + const serialized = { path: nfc, data: "serialized file bytes", flags: 4 }; + const resource = { path: "CAB-cafe.resS", data: "resource bytes" }; + const left = await inspect("left.unity3d", [serialized, resource]); + + // The same bytes with other raw node flags. + const flagged = await inspect("flags.unity3d", [ + { ...serialized, flags: 0 }, + resource, + ]); + expect(member(flagged, nfc).artifact_id).toBe(member(left, nfc).artifact_id); + expect(member(left, nfc).format_metadata).toMatchObject({ flags: 4 }); + expect(member(flagged, nfc).format_metadata).toMatchObject({ flags: 0 }); + expect(await memberChanges(left, flagged)).toEqual([ + { + classification: "changed", + logical_path: nfc, + dimensions: ["metadata"], + left_occurrence_id: member(left, nfc).occurrence_id, + right_occurrence_id: member(flagged, nfc).occurrence_id, + left_artifact_id: member(left, nfc).artifact_id, + right_artifact_id: member(flagged, nfc).artifact_id, + evidence_links: [left.evidence_id, flagged.evidence_id], + }, + ]); + + // A path stored decomposed pairs with its composed form, and the stored + // spelling is the member's declared source path. + const decomposed = await inspect("nfd.unity3d", [ + { ...serialized, path: "CAB-café" }, + resource, + ]); + expect(member(decomposed, nfc).format_metadata).toMatchObject({ + source_path: "CAB-café", + }); + expect(await memberChanges(left, decomposed)).toEqual([ + expect.objectContaining({ + classification: "changed", + logical_path: nfc, + dimensions: ["metadata"], + }), + ]); + + // Directory order and stream offsets are layout. + const reordered = await inspect("reordered.unity3d", [resource, serialized]); + expect(member(reordered, nfc).format_metadata).toMatchObject({ + index: 1, + declared_offset: Buffer.byteLength(resource.data), + }); + expect(member(left, nfc).format_metadata).toMatchObject({ + index: 0, + declared_offset: 0, + }); + expect(await memberChanges(left, reordered)).toEqual([]); +}); diff --git a/tests/acceptance/artifactComparisonZip.test.ts b/tests/acceptance/artifactComparisonZip.test.ts new file mode 100644 index 0000000000..efd85a20eb --- /dev/null +++ b/tests/acceptance/artifactComparisonZip.test.ts @@ -0,0 +1,132 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; +import { expect, it } from "vitest"; + +import type { ArtifactComparisonResult } from "../../src/domain/artifactComparison.js"; +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import type { Evidence } from "../../src/domain/evidence.js"; +import { connectArtifactComparisonMcp } from "../fixtures/artifactComparisonMcp.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** A ZIP member with the Unix mode its external attributes declare. */ +interface ZipMember { + readonly name: string; + readonly data: string; + readonly mode: number; + readonly password?: string; +} + +/** Inspect ZIP archives and compare their inventories through MCP. */ +const connectZipComparison = async () => { + const root = await createTestTempDirectory("rea-zip-comparison-"); + const { inspect: inspectFile, compare } = + await connectArtifactComparisonMcp(); + const inspect = async ( + name: string, + members: readonly ZipMember[], + ): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter(), { + versionMadeBy: 3 << 8, + }); + for (const { name: path, data, mode, password } of members) + await writer.add(path, new TextReader(data), { + externalFileAttributes: (mode << 16) >>> 0, + ...(password === undefined ? {} : { password }), + }); + const file = join(root, name); + await writeFile(file, await writer.close()); + return inspectFile(file); + }; + return { inspect, compare }; +}; + +const member = (evidence: Evidence, path: string) => { + const occurrence = artifactInventoryResultSchema + .parse(evidence.normalized_result) + .occurrences.find(({ logical_path }) => logical_path === path); + if (occurrence === undefined) throw new Error(`Missing ${path}`); + return occurrence; +}; + +/** The archive root differs whenever members do; compare members only. */ +const memberChanges = (result: ArtifactComparisonResult) => + result.changes.filter(({ logical_path }) => logical_path !== "."); + +it("reports a ZIP member that becomes a symlink", async () => { + const { inspect, compare } = await connectZipComparison(); + // A symlink member stores its target as data, so both members hold the + // same bytes; only the declared entry kind differs. + const file = await inspect("file.zip", [ + { name: "current", data: "target.txt", mode: 0o100644 }, + ]); + const link = await inspect("link.zip", [ + { name: "current", data: "target.txt", mode: 0o120777 }, + ]); + expect(member(file, "current")).toMatchObject({ + entry_kind: "file", + hash_status: "verified", + executable: false, + }); + // REA does not read symlink targets, so the link has no content. + expect(member(link, "current")).toMatchObject({ + entry_kind: "symlink", + artifact_id: null, + hash_status: "not-hashed", + executable: false, + }); + + // The entry kind change is known; whether the content changed is not. + expect(memberChanges(await compare(file, link))).toMatchObject([ + { + logical_path: "current", + classification: "unknown", + dimensions: ["metadata", "availability"], + left_occurrence_id: member(file, "current").occurrence_id, + right_occurrence_id: member(link, "current").occurrence_id, + }, + ]); + // Without a known difference, unread content stays unknown. + expect(memberChanges(await compare(link, link))).toMatchObject([ + { + logical_path: "current", + classification: "unknown", + dimensions: ["availability"], + }, + ]); +}); + +it("reports an executable mode change on an encrypted ZIP member", async () => { + const { inspect, compare } = await connectZipComparison(); + const tool = { name: "tool", data: "#!/bin/sh\n", password: "fixture" }; + const plain = await inspect("plain.zip", [{ ...tool, mode: 0o100644 }]); + const marked = await inspect("marked.zip", [{ ...tool, mode: 0o100755 }]); + // REA does not decrypt members, so neither side has content. + for (const [evidence, executable] of [ + [plain, false], + [marked, true], + ] as const) + expect(member(evidence, "tool")).toMatchObject({ + artifact_id: null, + encrypted: true, + hash_status: "unavailable", + executable, + }); + + // The mode change is known; whether the encrypted bytes changed is not. + expect(memberChanges(await compare(plain, marked))).toMatchObject([ + { + logical_path: "tool", + classification: "unknown", + dimensions: ["executable", "availability"], + }, + ]); + // Without a known difference, the encrypted bytes stay unknown. + expect(memberChanges(await compare(plain, plain))).toMatchObject([ + { + logical_path: "tool", + classification: "unknown", + dimensions: ["availability"], + }, + ]); +}); diff --git a/tests/acceptance/elfNotesAbiEndian.test.ts b/tests/acceptance/elfNotesAbiEndian.test.ts new file mode 100644 index 0000000000..819b11051b --- /dev/null +++ b/tests/acceptance/elfNotesAbiEndian.test.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + elfNote, + elfNoteFixture, +} from "../../src/native/elf/ElfNotes.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +// Literal big-endian descriptors keep the expected values independent of the +// fixture's integer writers and the parser's byte-order branch. +const knownAbi = Buffer.from("00000000010203041020304080010203", "hex"); +const unknownAbi = Buffer.from("01000000010203041020304080010203", "hex"); +const extendedAbi = Buffer.concat([knownAbi, Buffer.from("01020304", "hex")]); +const shortAbi = knownAbi.subarray(0, 12); +const descriptorHash = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +for (const { bits, ownership, machine } of [ + { bits: 32, ownership: "program", machine: 20 }, + { bits: 64, ownership: "section", machine: 21 }, +] as const) + cliTest( + `preserves big-endian ELF${String(bits)} ABI facts through CLI and stdio MCP`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-abi-endian-"); + const path = join(root, "selected.elf"); + const descriptors = [ + knownAbi, + unknownAbi, + shortAbi, + extendedAbi, + knownAbi, + ]; + const notes = Buffer.concat([ + ...descriptors + .slice(0, 4) + .map((bytes) => elfNote("GNU", 1, bytes, false)), + elfNote("OTHER", 1, knownAbi, false), + ]); + const bytes = elfNoteFixture({ + bits, + ownership, + machine, + little: false, + notes, + }); + await writeFile(path, bytes); + + const output = await cli.sharedCliRun!({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const call = await cli.sharedMcpCall!(); + const response = await call("inspect_elf_notes", { path }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + + const schema = toolContract("inspect_elf_notes").outputSchema; + const reports = [output.json, response.structuredContent].map((value) => { + const evidence = schema.parse(value); + expect(evidence.subject).toMatchObject({ + local_path: path, + digest: { sha256: descriptorHash(bytes) }, + }); + const report = evidence.normalized_result; + expect(report).toMatchObject({ + format: `elf${String(bits)}`, + byte_order: "big", + machine, + coverage: { + status: "complete", + examined_tables: 1, + examined_entries: 5, + }, + }); + expect(report.entries).toHaveLength(5); + expect(report.entries[0]?.gnu?.abi_tag).toEqual({ + os: 0, + os_name: "linux", + major: 0x01020304, + minor: 0x10203040, + subminor: 0x80010203, + }); + expect(report.entries[1]?.gnu?.abi_tag).toEqual({ + os: 0x01000000, + os_name: null, + major: 0x01020304, + minor: 0x10203040, + subminor: 0x80010203, + }); + // Unexpected lengths stay raw; no ABI values are invented or truncated. + for (const index of [2, 3]) + expect(report.entries[index]?.gnu?.abi_tag).toBeNull(); + expect(report.entries[4]).toMatchObject({ + owner: "OTHER", + type_name: "unknown", + gnu: null, + }); + for (const [index, descriptor] of descriptors.entries()) + expect(report.entries[index]?.descriptor).toMatchObject({ + bytes: descriptor.length, + sha256: descriptorHash(descriptor), + }); + return report; + }); + expect(reports[0]).toEqual(reports[1]); + expect((await readFile(path)).equals(bytes)).toBe(true); + }, + ); diff --git a/tests/acceptance/elfNotesCore.test.ts b/tests/acceptance/elfNotesCore.test.ts new file mode 100644 index 0000000000..7f9076cd25 --- /dev/null +++ b/tests/acceptance/elfNotesCore.test.ts @@ -0,0 +1,185 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { + coreAuxvNote, + coreFileNote, + corePrpsinfoNote, + coreSiginfoNote, + elfNoteFixture, +} from "../../src/native/elf/ElfNotes.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const descriptorHash = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +const buildCoreNotes = (): Buffer => + Buffer.concat([ + corePrpsinfoNote({ + bits: 64, + little: true, + state: 0, + sname: "R", + pid: 31337, + ppid: 1, + pgrp: 31337, + sid: 31337, + uid: 1000, + gid: 1000, + fname: "target-service", + psargs: "/opt/target-service --worker 1", + }), + coreFileNote( + [ + { + start: 0x555555554000n, + end: 0x555555558000n, + file_ofs: 0n, + path: "/opt/target-service", + }, + { + start: 0x7ffff7dc0000n, + end: 0x7ffff7de0000n, + file_ofs: 0n, + path: "/lib/x86_64-linux-gnu/libc.so.6", + }, + ], + 4096, + 64, + true, + ), + coreSiginfoNote(11, 1, 0, true), + coreAuxvNote( + [ + [6, 4096n], + [3, 0x555555554040n], + ], + 64, + true, + ), + ]); + +const assertCoreEntries = (entries: unknown[]): void => { + expect(entries).toHaveLength(4); + expect(entries[0]).toMatchObject({ + owner: "CORE", + type: 3, + type_name: "core_prpsinfo", + gnu: null, + core: { + prpsinfo: { + state: 0, + pid: 31337, + ppid: 1, + pgrp: 31337, + sid: 31337, + uid: 1000, + gid: 1000, + filename: "target-service", + psargs: "/opt/target-service --worker 1", + }, + }, + }); + expect(entries[1]).toMatchObject({ + owner: "CORE", + type: 0x46494c45, + type_name: "core_file", + gnu: null, + core: { + file: { + count: 2, + page_size: 4096, + mappings: [ + { + start_address: "0x555555554000", + end_address: "0x555555558000", + file_offset_pages: "0x0", + path: "/opt/target-service", + path_hex: Buffer.from("/opt/target-service").toString("hex"), + }, + { + start_address: "0x7ffff7dc0000", + end_address: "0x7ffff7de0000", + file_offset_pages: "0x0", + path: "/lib/x86_64-linux-gnu/libc.so.6", + path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString( + "hex", + ), + }, + ], + }, + }, + }); + expect(entries[2]).toMatchObject({ + owner: "CORE", + type: 0x53494749, + type_name: "core_siginfo", + gnu: null, + core: { + siginfo: { signo: 11, errno: 0, code: 1 }, + }, + }); + expect(entries[3]).toMatchObject({ + owner: "CORE", + type: 6, + type_name: "core_auxv", + gnu: null, + core: { + auxv: [ + { type: 6, type_name: "AT_PAGESZ", value: "0x1000" }, + { type: 3, type_name: "AT_PHDR", value: "0x555555554040" }, + ], + }, + }); +}; + +cliTest( + "decodes Linux core dump notes through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-core-notes-"); + const path = join(root, "core.elf"); + const notes = buildCoreNotes(); + const bytes = elfNoteFixture({ + bits: 64, + little: true, + type: 4, + ownership: "program", + notes, + }); + await writeFile(path, bytes); + + const output = await cli.sharedCliRun!({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + + const call = await cli.sharedMcpCall!(); + const response = await call("inspect_elf_notes", { path }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + + const schema = toolContract("inspect_elf_notes").outputSchema; + for (const raw of [output.json, response.structuredContent]) { + const evidence = schema.parse(raw); + expect(evidence.subject).toMatchObject({ + local_path: path, + digest: { sha256: descriptorHash(bytes) }, + }); + const report = evidence.normalized_result; + expect(report).toMatchObject({ + format: "elf64", + byte_order: "little", + machine: 62, + coverage: { + status: "complete", + examined_tables: 1, + examined_entries: 4, + }, + }); + assertCoreEntries(report.entries); + } + }, +); diff --git a/tests/acceptance/harmonyArtifactJourney.test.ts b/tests/acceptance/harmonyArtifactJourney.test.ts index c0b78a2c6d..2b383e292c 100644 --- a/tests/acceptance/harmonyArtifactJourney.test.ts +++ b/tests/acceptance/harmonyArtifactJourney.test.ts @@ -59,6 +59,8 @@ const harmonyFixture = async () => { await writer.add("ets/current", new TextReader("modules.abc"), { unixMode: 0o120777, }); + await writer.add("pkgContextInfo.json", new TextReader("{}")); + await writer.add(".pages.info", new TextReader("page metadata fixture")); const javascriptPaths = ["cts", "jsx", "mts", "ts", "tsx"].map( (extension) => `assets/js/index.${extension}`, ); @@ -67,6 +69,43 @@ const harmonyFixture = async () => { return { bytes: await writer.close(), javascriptPaths }; }; +/** Assert the projected Harmony graph for one archive format over the MCP adapter. */ +const expectHarmonyProjection = ( + graph: HarmonyApplicationProjectionResult, + format: string, + sha256: string, + javascriptPaths: readonly string[], +): void => { + expect(graph).toMatchObject({ + root_format: format, + root_sha256: sha256, + coverage: { status: "complete-within-inventory" }, + unavailable_components: [], + unclassified_components: [ + expect.objectContaining({ + path: ".pages.info", + sha256: createHash("sha256") + .update("page metadata fixture") + .digest("hex"), + }), + expect.objectContaining({ + path: "pkgContextInfo.json", + sha256: createHash("sha256").update("{}").digest("hex"), + }), + ], + }); + expect(graph.components.manifests.map(({ path: member }) => member)).toEqual([ + "module.json", + ]); + expect(graph.components.bytecode.map(({ path: member }) => member)).toEqual([ + "ets/modules.abc", + ]); + expect(graph.components.javascript.map(({ path: member }) => member)).toEqual( + javascriptPaths, + ); + expect(graph.runtime_families).toEqual(["ark", "javascript"]); +}; + test("preserves Harmony archive formats through the public CLI/MCP lifecycle", async ({ cli, workspace, @@ -157,22 +196,7 @@ test("preserves Harmony archive formats through the public CLI/MCP lifecycle", a const graph = harmonyApplicationProjectionResultSchema.parse( parseEvidence(projected.structuredContent).normalized_result, ); - expect(graph).toMatchObject({ - root_format: format, - root_sha256: sha256, - coverage: { status: "complete-within-inventory" }, - unavailable_components: [], - }); - expect( - graph.components.manifests.map(({ path: member }) => member), - ).toEqual(["module.json"]); - expect( - graph.components.bytecode.map(({ path: member }) => member), - ).toEqual(["ets/modules.abc"]); - expect( - graph.components.javascript.map(({ path: member }) => member), - ).toEqual(javascriptPaths); - expect(graph.runtime_families).toEqual(["ark", "javascript"]); + expectHarmonyProjection(graph, format, sha256, javascriptPaths); const projectionInput = workspace.path("projection-input.json"); await writeFile( projectionInput, diff --git a/tests/acceptance/hexRecordsCli.test.ts b/tests/acceptance/hexRecordsCli.test.ts index f085181eba..3c2169895a 100644 --- a/tests/acceptance/hexRecordsCli.test.ts +++ b/tests/acceptance/hexRecordsCli.test.ts @@ -39,6 +39,32 @@ const cases = [ ), }, }, + { + encoding: "srec", + name: "firmware.s28", + text: "S208FEDCBADEADBEEF2B\nS804FEDCBA67\n", + root: { + variant: "S28", + start_address: 0xfe_dcba, + start_address_record: "S8", + }, + regions: { + "0x00fedcba-0x00fedcbd.bin": Buffer.from("deadbeef", "hex"), + }, + }, + { + encoding: "srec", + name: "firmware.s37", + text: "S309FEDCBA98DEADBEEF92\nS705FEDCBA98CE\n", + root: { + variant: "S37", + start_address: 0xfedc_ba98, + start_address_record: "S7", + }, + regions: { + "0xfedcba98-0xfedcba9b.bin": Buffer.from("deadbeef", "hex"), + }, + }, ] as const; cliTest.for(cases)( @@ -135,6 +161,7 @@ cliTest.for(cases)( expect( Buffer.compare(await readFile(join(result.output_root, path)), bytes), ).toBe(0); + expect((await readFile(source)).equals(Buffer.from(text))).toBe(true); }, ); diff --git a/tests/acceptance/peCertificatesCli.test.ts b/tests/acceptance/peCertificatesCli.test.ts index c068c47ecb..1a877b942c 100644 --- a/tests/acceptance/peCertificatesCli.test.ts +++ b/tests/acceptance/peCertificatesCli.test.ts @@ -272,6 +272,40 @@ cliTest( }, ); +cliTest( + "rejects a certificate validity date that is not a calendar day through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pe-cert-cli-validity-"); + const path = join(root, "validity.exe"); + const payload = pkcs7SignedDataFixture(); + const timeAt = payload.indexOf(Buffer.from("250101000000Z")); + expect(timeAt).toBeGreaterThanOrEqual(0); + // 2025 has no 29 February; the equal-length edit keeps every DER length. + payload.write("250229000000Z", timeAt, "ascii"); + await writeFile(path, peCertificateFixture(false, payload)); + const invalid = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(invalid.exitCode).toBe(1); + expect(invalid.json).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_pe_certificates" }, + }); + payload.write("240229000000Z", timeAt, "ascii"); + await writeFile(path, peCertificateFixture(false, payload)); + const valid = await cli.run({ + arguments: ["inspect-pe-certificates", path, "--json"], + }); + expect(valid.exitCode, valid.stderr).toBe(0); + const evidence = toolContract("inspect_pe_certificates").outputSchema.parse( + valid.json, + ); + expect( + evidence.normalized_result.entries[0]?.pkcs7?.certificates[0]?.not_before, + ).toBe("240229000000Z"); + }, +); + cliTest( "inventories repeated certificates within both decoder allowances", async ({ cli }) => { diff --git a/tests/acceptance/retainedComparisonAggregation.test.ts b/tests/acceptance/retainedComparisonAggregation.test.ts index 61a63170cf..0e7792e7a4 100644 --- a/tests/acceptance/retainedComparisonAggregation.test.ts +++ b/tests/acceptance/retainedComparisonAggregation.test.ts @@ -87,68 +87,113 @@ it("recovers a missing retained closure by importing the originating bundle", as expect(parseEvidence(retained.structuredContent)).toEqual(aggregate); }); -it("aggregates selected retained observations within a caller's input budget", async () => { - // Distinct authenticated observations may have identical derived contents. - const comparisons = Array.from({ length: 12 }, (_, observation) => - createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { - operation: "compare_functions", - predicateType: "rea.function-comparison", - parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, - result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, - confidence: "derived", - authority: "analyst-inference", - limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, - evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, - }), - ); - const directory = await createTestTempDirectory("rea-retained-comparisons-"); - const path = join(directory, "comparisons.json"); - await writeFile( - path, - JSON.stringify( - createEvidenceBundle([ - FUNCTION_COMPARISON_EXAMPLE.left, - FUNCTION_COMPARISON_EXAMPLE.right, - ...comparisons, - ]), - ), - ); - const { call } = await connectLocalToolsMcp({ - REA_MCP_MAX_INPUT_ELEMENTS: "1024", - }); - const imported = await call("import_evidence_bundle", { path }); - expect(imported.isError, JSON.stringify(imported)).not.toBe(true); - const inline = await call("find_changed_behavior", { comparisons }); - expect(parseMcpToolError(inline).error).toMatchObject({ - code: "resource_constraint", - details: { reported_limits: { max_input_elements: 1024 } }, - }); - const response = await call("find_changed_behavior", { - comparisons: comparisons.map(({ evidence_id }) => ({ - kind: "retained-evidence", - evidence_id, - })), - }); - expect(response.isError, JSON.stringify(response)).not.toBe(true); - const aggregate = parseEvidence(response.structuredContent); - const result = changedBehaviorResultSchema.parse(aggregate.normalized_result); - expect(result.behavior_status).toBe("unknown"); - expect(result.summary.observed_changes).toBe(0); - expect(aggregate.parameters.comparison_evidence_ids).toEqual( - comparisons.map(({ evidence_id }) => evidence_id), - ); - expect( - new Set( - result.findings.items.map( - ({ source_comparison_id }) => source_comparison_id, +it.each(["compact", "full"] as const)( + "aggregates retained observations within the %s input profile budget", + async (profile) => { + // Distinct authenticated observations may have identical derived contents. + const comparisons = Array.from({ length: 12 }, (_, observation) => + createEvidence(undefined, FUNCTION_COMPARISON_EVIDENCE.provider, { + operation: "compare_functions", + predicateType: "rea.function-comparison", + parameters: { ...FUNCTION_COMPARISON_EVIDENCE.parameters, observation }, + result: FUNCTION_COMPARISON_EVIDENCE.normalized_result, + confidence: "derived", + authority: "analyst-inference", + limitations: FUNCTION_COMPARISON_EVIDENCE.limitations, + evidenceLinks: FUNCTION_COMPARISON_EVIDENCE.evidence_links, + }), + ); + const directory = await createTestTempDirectory( + "rea-retained-comparisons-", + ); + const path = join(directory, "comparisons.json"); + await writeFile( + path, + JSON.stringify( + createEvidenceBundle([ + FUNCTION_COMPARISON_EXAMPLE.left, + FUNCTION_COMPARISON_EXAMPLE.right, + ...comparisons, + ]), ), - ), - ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); - expect(aggregate.evidence_links).toEqual( - expect.arrayContaining([ - FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, - FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, - ...comparisons.map(({ evidence_id }) => evidence_id), - ]), - ); -}); + ); + const { call } = await connectLocalToolsMcp({ + REA_MCP_MAX_INPUT_ELEMENTS: "1024", + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + // Both refusals happen before any call initializes the deferred runtime. + const inline = await call("find_changed_behavior", { comparisons }); + expect(parseMcpToolError(inline).error).toMatchObject({ + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + details: { reported_limits: { max_input_elements: 1024 } }, + }); + const unrelated = parseMcpToolError( + await call("read_bytes", { + address: "0x1000", + length: 1, + extra: Array.from({ length: 1024 }, () => 0), + }), + ).error; + expect(unrelated.code).toBe("resource_constraint"); + expect(unrelated.remediation.action).not.toContain("retained-evidence"); + const inlineView = parseMcpToolError( + await call("inspect_analysis_view", { + source: { + kind: "inline", + evidence: { + ...FUNCTION_COMPARISON_EXAMPLE.left, + limitations: Array.from({ length: 1024 }, () => "limit"), + }, + }, + view: { kind: "summary" }, + }), + ).error; + expect(inlineView.code).toBe("resource_constraint"); + expect(inlineView.remediation.action).toContain("retained-evidence"); + const referenceOnly = parseMcpToolError( + await call("export_environment_bundle", { + source: { + kind: "retained-evidence", + evidence_id: FUNCTION_COMPARISON_EVIDENCE.evidence_id, + }, + output_directory: "/unused", + template_rules: Array.from({ length: 1024 }, () => "unused"), + }), + ).error; + expect(referenceOnly.code).toBe("resource_constraint"); + expect(referenceOnly.remediation.action).not.toContain("retained-evidence"); + const imported = await call("import_evidence_bundle", { path }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + const response = await call("find_changed_behavior", { + comparisons: comparisons.map(({ evidence_id }) => ({ + kind: "retained-evidence", + evidence_id, + })), + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const aggregate = parseEvidence(response.structuredContent); + const result = changedBehaviorResultSchema.parse( + aggregate.normalized_result, + ); + expect(result.behavior_status).toBe("unknown"); + expect(result.summary.observed_changes).toBe(0); + expect(aggregate.parameters.comparison_evidence_ids).toEqual( + comparisons.map(({ evidence_id }) => evidence_id), + ); + expect( + new Set( + result.findings.items.map( + ({ source_comparison_id }) => source_comparison_id, + ), + ), + ).toEqual(new Set(comparisons.map(({ evidence_id }) => evidence_id))); + expect(aggregate.evidence_links).toEqual( + expect.arrayContaining([ + FUNCTION_COMPARISON_EXAMPLE.left.evidence_id, + FUNCTION_COMPARISON_EXAMPLE.right.evidence_id, + ...comparisons.map(({ evidence_id }) => evidence_id), + ]), + ); + }, +); diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index 0658501bb0..9d461b36bb 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; +import { RUST_RESOURCE_GUARD_LIMITATION } from "../../src/rust/RustBinaryProvider.js"; import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { compressRustElfSectionNames, @@ -465,6 +466,9 @@ cliTest( expect(metadata?.uncompressed_bytes).toBeLessThanOrEqual( (metadata?.compressed_bytes ?? 0) * 64, ); + // Padding separates the section size from the consumed stream that the + // inflate guidance names. + expect(evidence.limitations).toContain(RUST_RESOURCE_GUARD_LIMITATION); expect(evidence.locations).toContainEqual({ kind: "file-offset-range", start: metadata?.file_offset, diff --git a/tests/acceptance/setup/automaticClientSetup.test.ts b/tests/acceptance/setup/automaticClientSetup.test.ts new file mode 100644 index 0000000000..ea93f2119c --- /dev/null +++ b/tests/acceptance/setup/automaticClientSetup.test.ts @@ -0,0 +1,95 @@ +import { access, mkdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for([ + { marker: true, skill: true }, + { marker: true, skill: false }, + { marker: false, skill: true }, +])( + "automatic setup excludes manual hosted connectors (marker: $marker, skill: $skill)", + async ({ marker, skill }, { cli }) => { + const home = await createTestTempDirectory("rea-automatic-clients-"); + if (marker) await mkdir(join(home, ".grokbot")); + const run = () => + cli.run({ + arguments: [ + "setup", + "--all-detected", + "--yes", + "--json", + ...(skill ? [] : ["--skill=false"]), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + const first = await run(); + expect(first.exitCode, first.stdout).toBe(0); + expect(first.json).toMatchObject({ + status: "ready", + appliedActions: skill ? ["installed_skill"] : [], + clients: {}, + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + if (marker) + expect(first.json).toMatchObject({ + clientStates: expect.arrayContaining([ + expect.objectContaining({ + client: expect.objectContaining({ name: "grok_bot" }), + detected: true, + }), + ]), + }); + const repeated = await run(); + expect(repeated.exitCode, repeated.stdout).toBe(0); + expect(repeated.json).toMatchObject({ + status: "ready", + plannedActions: [], + appliedActions: [], + doctor: { healthy: true, scope: { clients: [], skill } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest.for([true, false])( + "explicit Grok Bot setup retains manual instructions (all detected: %s)", + async (allDetected, { cli }) => { + const home = await createTestTempDirectory("rea-explicit-manual-client-"); + await mkdir(join(home, ".grokbot")); + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "grok_bot", + "--skill=false", + "--yes", + "--json", + ...(allDetected ? ["--all-detected"] : []), + ], + cwd: home, + environment: { HOME: home, USERPROFILE: home, TMPDIR: home }, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining("hosted computer"), + doctor: { scope: { clients: ["grok_bot"] } }, + }); + await expect( + access(join(home, ".grokbot", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); diff --git a/tests/acceptance/setup/repeatedClientSetup.test.ts b/tests/acceptance/setup/repeatedClientSetup.test.ts index def26a75bb..143ed803d1 100644 --- a/tests/acceptance/setup/repeatedClientSetup.test.ts +++ b/tests/acceptance/setup/repeatedClientSetup.test.ts @@ -88,6 +88,7 @@ cliTest( "rea-explicit-detected-clients-", ); await mkdir(join(home, ".codex")); + await mkdir(join(home, ".grokbot")); const run = (selection: readonly string[], mode: string) => cli.run({ arguments: ["setup", ...selection, "--skill=false", "--json", mode], diff --git a/tests/acceptance/setup/updateAliasCli.test.ts b/tests/acceptance/setup/updateAliasCli.test.ts index 9fd0536d38..7587c4638b 100644 --- a/tests/acceptance/setup/updateAliasCli.test.ts +++ b/tests/acceptance/setup/updateAliasCli.test.ts @@ -77,6 +77,21 @@ cliTest( const alias = await run(["--json", "--update"]); expect(alias.exitCode, alias.stderr).toBe(0); expect(alias.json).toEqual(canonical.json); + for (const flag of ["--no-timings", "--timings=false", "--timings=true"]) { + const canonicalTimed = await run(["update", "--json", flag]); + const aliasTimed = await run([flag, "--update", "--json"]); + expect(canonicalTimed.exitCode, canonicalTimed.stdout).toBe(0); + expect(aliasTimed.exitCode, aliasTimed.stdout).toBe(0); + expect(aliasTimed.json).toEqual(canonicalTimed.json); + expect(aliasTimed.json).toEqual(canonical.json); + for (const result of [canonicalTimed, aliasTimed]) { + if (flag === "--timings=true") + expect(JSON.parse(result.stderr)).toMatchObject({ + rea_timings: { version: 1, command_ms: expect.any(Number) }, + }); + else expect(result.stderr).toBe(""); + } + } const filtered = await run([ "--update", "--format=json", diff --git a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts index e36bc6659a..a465ce02b8 100644 --- a/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts +++ b/tests/acceptance/tools/inspectorFileUrlIdentity.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { spawn } from "node:child_process"; import { once } from "node:events"; import { readFile, writeFile } from "node:fs/promises"; @@ -117,9 +118,11 @@ it("preserves file URL scheme spelling and script identity after real Inspector const retained = await call("get_evidence_bundle", {}); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const records = toolContract("get_evidence_bundle").outputSchema.parse( - retained.structuredContent, - ).result.records; + const { records } = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); const original = records.find( (record) => record.evidence_id === first.evidence_id, ); diff --git a/tests/acceptance/tools/nativeFunctionMatching.test.ts b/tests/acceptance/tools/nativeFunctionMatching.test.ts index dd1fdceabe..c8cd0a6f44 100644 --- a/tests/acceptance/tools/nativeFunctionMatching.test.ts +++ b/tests/acceptance/tools/nativeFunctionMatching.test.ts @@ -653,3 +653,319 @@ cliTest( }, 60000, ); + +cliTest.for([{ profile: "full" }, { profile: "compact" }] as const)( + "ties byte qualifications to selected observations through paged and offline MCP ($profile)", + { timeout: 60000 }, + async ({ profile }) => { + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "left" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "right" }, + ]); + const [leftInfo] = nativeMatchingDetails("1", "0x10", "c3"); + const [rightInfo] = nativeMatchingDetails("2", "0x20", "c3"); + const read = (side: "1" | "2", address: string, limitation: string) => + createEvidence(nativeMatchingTarget(side), nativeMatchingProvider, { + operation: "read_bytes", + parameters: { address, length: 1 }, + result: { + address, + requested_bytes: 1, + returned_bytes: 1, + bytes_hex: "c3", + complete: true, + }, + limitations: [limitation], + }); + const first = read("1", "0x10", "First selected source limitation"); + const duplicate = read("1", "0x10", "Alternative source limitation"); + const unused = read("1", "0x99", "Unused source limitation"); + const other = read("2", "0x20", "Right source limitation"); + const reference = (evidence_id: string) => ({ + kind: "retained-evidence", + evidence_id, + }); + const { call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }); + const execute = async ( + firstRead: typeof first, + secondRead: typeof first, + ) => { + const reply = await call("match_native_functions", { + left, + right, + left_observations: [leftInfo, firstRead, firstRead, secondRead, unused], + right_observations: [rightInfo, other], + detail: "complete", + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + return parseEvidence(reply.structuredContent); + }; + const evidence = await execute(first, duplicate); + const report = nativeFunctionMatchingSchema.parse( + evidence.normalized_result, + ); + const match = report.matches.find( + (item) => item.basis === "identical-bytes", + ); + expect(match?.left[0]?.evidence_links).toContain(first.evidence_id); + expect(match?.left[0]?.evidence_links).not.toContain(duplicate.evidence_id); + expect(match?.right[0]?.evidence_links).toContain(other.evidence_id); + expect(report.byte_sources).toEqual([ + { + side: "left", + evidence_id: first.evidence_id, + limitations: ["First selected source limitation"], + }, + { + side: "right", + evidence_id: other.evidence_id, + limitations: ["Right source limitation"], + }, + ]); + const alternate = nativeFunctionMatchingSchema.parse( + (await execute(duplicate, first)).normalized_result, + ); + expect( + alternate.matches.find((item) => item.basis === "identical-bytes") + ?.left[0]?.evidence_links, + ).toContain(duplicate.evidence_id); + expect(alternate.byte_sources?.map((item) => item.evidence_id)).toEqual([ + duplicate.evidence_id, + other.evidence_id, + ]); + const sourcePage = async (caller: typeof call, offset: number) => { + const reply = await caller("inspect_analysis_view", { + source: reference(evidence.evidence_id), + view: { + kind: "function-matches", + collection: "byte-sources", + offset, + limit: 1, + }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + return parseEvidence(reply.structuredContent).normalized_result; + }; + expect(await sourcePage(call, 0)).toMatchObject({ + items: [ + { + side: "left", + evidence_id: first.evidence_id, + limitations: ["First selected source limitation"], + }, + ], + coverage: { total: 2, next_offset: 1 }, + }); + expect(await sourcePage(call, 1)).toMatchObject({ + items: [ + { + side: "right", + evidence_id: other.evidence_id, + limitations: ["Right source limitation"], + }, + ], + coverage: { total: 2, next_offset: null }, + }); + const root = await createTestTempDirectory("rea-match-byte-sources-"); + const path = join(root, "bundle.json"); + const exported = await call("export_evidence_bundle", { path }); + expect(exported.isError, JSON.stringify(exported)).not.toBe(true); + const offline = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }); + const imported = await offline.call("import_evidence_bundle", { path }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + expect(await sourcePage(offline.call, 0)).toMatchObject({ + items: [{ evidence_id: first.evidence_id }], + }); + }, +); + +cliTest( + "bounds byte-source pages and recovers an oversized source", + { timeout: 60000 }, + async () => { + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "left" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "right" }, + ]); + const [info] = nativeMatchingDetails("1", "0x10", "00".repeat(256)); + if (info === undefined) throw new Error("Missing synthetic body"); + const reads = Array.from({ length: 256 }, (_, index) => { + const address = `0x${(16 + index).toString(16)}`; + return createEvidence(nativeMatchingTarget("1"), nativeMatchingProvider, { + operation: "read_bytes", + parameters: { address, length: 1 }, + result: { + address, + requested_bytes: 1, + returned_bytes: 1, + bytes_hex: "00", + complete: true, + }, + limitations: [ + `${index}:` + + "x".repeat(index === 0 ? 1_100_000 : index === 1 ? 6000 : 4500), + ], + }); + }); + const reference = (evidence_id: string) => ({ + kind: "retained-evidence", + evidence_id, + }); + const root = await createTestTempDirectory("rea-match-large-source-"); + const path = join(root, "inputs.json"); + await writeFile( + path, + JSON.stringify(createEvidenceBundle([left, right, info, ...reads])), + ); + const { call } = await connectLocalToolsMcp(); + const imported = await call("import_evidence_bundle", { path }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + const result = await call("match_native_functions", { + left: reference(left.evidence_id), + right: reference(right.evidence_id), + left_observations: [info, ...reads].map((record) => + reference(record.evidence_id), + ), + detail: "summary", + }); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + const parent = parseEvidence(result.structuredContent) + .normalized_result as { parent_evidence_id: string }; + const oversized = await call("inspect_analysis_view", { + source: reference(parent.parent_evidence_id), + view: { kind: "function-matches", collection: "byte-sources" }, + }); + expect(parseMcpToolError(oversized).error).toMatchObject({ + code: "resource_constraint", + details: { + resource: "transport", + reported_limits: { + source_evidence_id: reads[0]!.evidence_id, + next_offset: 1, + }, + }, + }); + let offset = 1; + const ids: string[] = [reads[0]!.evidence_id]; + let limited = false; + while (true) { + const reply = await call("inspect_analysis_view", { + source: reference(parent.parent_evidence_id), + view: { + kind: "function-matches", + collection: "byte-sources", + offset, + limit: 1000, + }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const page = parseEvidence(reply.structuredContent).normalized_result as { + byte_limited: boolean; + items: { evidence_id: string; limitations: string[] }[]; + coverage: { total: number; next_offset: number | null }; + }; + limited ||= page.byte_limited; + if (offset === 1) + expect(page.items[0]?.limitations).toEqual(reads[1]!.limitations); + ids.push(...page.items.map((item) => item.evidence_id)); + expect(page.coverage.total).toBe(256); + if (page.coverage.next_offset === null) break; + expect(page.coverage.next_offset).toBe(ids.length); + offset = page.coverage.next_offset; + } + expect(limited).toBe(true); + expect(ids).toEqual(reads.map((record) => record.evidence_id)); + const original = await call("get_evidence_bundle", { + detail: "records", + filters: { evidence_id: reads[0]!.evidence_id }, + }); + expect(original.isError, JSON.stringify(original)).not.toBe(true); + expect(JSON.stringify(original.structuredContent)).toContain( + reads[0]!.limitations[0], + ); + }, +); + +cliTest( + "oversized inline matching Evidence points back to the caller's original record", + { timeout: 60000 }, + async () => { + const left = nativeMatchingInventory("1", [ + { address: "0x10", value: "left" }, + ]); + const right = nativeMatchingInventory("2", [ + { address: "0x20", value: "right" }, + ]); + const [info] = nativeMatchingDetails("1", "0x10", "c3"); + if (info === undefined) throw new Error("Missing synthetic body"); + const read = createEvidence( + nativeMatchingTarget("1"), + nativeMatchingProvider, + { + operation: "read_bytes", + parameters: { address: "0x10", length: 1 }, + result: { + address: "0x10", + requested_bytes: 1, + returned_bytes: 1, + bytes_hex: "c3", + complete: true, + }, + limitations: ["x".repeat(1_100_000)], + }, + ); + const { call } = await connectLocalToolsMcp(); + const match = await call("match_native_functions", { + left, + right, + left_observations: [info, read], + detail: "complete", + }); + expect(match.isError, JSON.stringify(match)).not.toBe(true); + const matchingEvidence = parseEvidence(match.structuredContent); + const offline = await connectLocalToolsMcp(); + const page = await offline.call("inspect_analysis_view", { + source: { kind: "inline", evidence: matchingEvidence }, + view: { kind: "function-matches", collection: "byte-sources" }, + }); + const error = parseMcpToolError(page).error; + expect(error).toMatchObject({ + code: "resource_constraint", + details: { + reported_limits: { + source_evidence_id: read.evidence_id, + next_offset: 1, + }, + }, + }); + const report = nativeFunctionMatchingSchema.parse( + matchingEvidence.normalized_result, + ); + expect(report.byte_sources).toEqual([ + { + side: "left", + evidence_id: read.evidence_id, + limitations: read.limitations, + }, + ]); + const lookup = await offline.call("get_evidence_bundle", { + detail: "records", + filters: { evidence_id: read.evidence_id }, + }); + expect(lookup.isError, JSON.stringify(lookup)).not.toBe(true); + expect( + (lookup.structuredContent as { result: { records: unknown[] } }).result + .records, + ).toEqual([]); + }, +); diff --git a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts index 9ff527edda..0463442fe6 100644 --- a/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts +++ b/tests/acceptance/tools/reconstructionScopedUnknowns.test.ts @@ -1,3 +1,4 @@ +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; import { expect, it } from "vitest"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; @@ -141,9 +142,11 @@ it.each(["pseudocode", "overall"] as const)( claims: { items: [{ unknown_ids: [] }] }, }); const exported = await call("get_evidence_bundle", { detail: "complete" }); - const bundle = toolContract("get_evidence_bundle").outputSchema.parse( - exported.structuredContent, - ).result; + const bundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + exported.structuredContent, + ).result, + ); for (const source of [left, right, comparison]) expect( bundle.records.find( diff --git a/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts b/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts new file mode 100644 index 0000000000..fc1a27e2b5 --- /dev/null +++ b/tests/acceptance/tools/unavailableNativeCollectionViews.test.ts @@ -0,0 +1,154 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + nativeLoadImageObservationFixture, + nativeRawLoadImageFixture, +} from "../../fixtures/nativeLoadImage.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "distinguishes unavailable native collections from empty selections through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-unavailable-native-view-"); + const provider = { + id: "fixture", + name: "Retained native fixture", + version: "1", + }; + const load = (result: unknown) => + createEvidence(undefined, provider, { + operation: "inspect_native_load_image", + parameters: {}, + result, + limitations: [ + "Synthetic retained Evidence; no native provider was run.", + ], + }); + const unsupported = load({ + status: "unsupported", + reason: "Independent ELF load verification was not recorded.", + observations: { + ...nativeLoadImageObservationFixture(), + executable_format: "Executable and Linking Format (ELF)", + }, + limitations: [], + }); + const raw = load(nativeRawLoadImageFixture()); + const primitive = createEvidence(undefined, provider, { + operation: "procedure_callees", + parameters: {}, + result: [], + }); + const graph = createEvidence(undefined, provider, { + operation: "get_call_graph", + parameters: {}, + result: { graph: {}, limitations: [] }, + }); + const unavailable = { + status: "unavailable", + examined: 0, + total: null, + next_offset: null, + exhausted: false, + }; + const empty = (total: number) => ({ + status: "empty", + examined: 0, + total, + next_offset: null, + exhausted: true, + }); + const cases = [ + { + label: "checks-unrecorded", + evidence: unsupported, + view: { kind: "native-load-image", collection: "checks" }, + rows: null, + coverage: unavailable, + }, + { + label: "sections-unrecorded-despite-empty-selection", + evidence: unsupported, + view: { + kind: "native-load-image", + collection: "sections", + offset: 5, + indices: [], + }, + rows: null, + coverage: unavailable, + }, + { + label: "checks-recorded-empty", + evidence: raw, + view: { kind: "native-load-image", collection: "checks" }, + rows: [], + coverage: empty(0), + }, + { + label: "mappings-empty-selection", + evidence: unsupported, + view: { + kind: "native-load-image", + collection: "mappings", + indices: [], + }, + rows: [], + coverage: empty(0), + }, + { + label: "mappings-past-end", + evidence: unsupported, + view: { kind: "native-load-image", collection: "mappings", offset: 5 }, + rows: [], + coverage: empty(1), + }, + { + label: "primitive-result-limitations-inapplicable", + evidence: primitive, + view: { kind: "native-context", facet: "result-limitations" }, + rows: null, + coverage: unavailable, + }, + { + label: "graph-result-limitations-recorded-empty", + evidence: graph, + view: { kind: "native-context", facet: "result-limitations" }, + rows: [], + coverage: empty(0), + }, + ]; + const call = await cli.sharedMcpCall!(); + const run = cli.sharedCliRun!.bind(cli); + for (const test of cases) { + const request = { + source: { kind: "inline", evidence: test.evidence }, + view: test.view, + }; + const path = join(root, `${test.label}.json`); + await writeFile(path, JSON.stringify(request)); + const output = await run({ + arguments: ["inspect-analysis-view", path, "--json"], + }); + expect(output.exitCode, output.stderr || output.stdout).toBe(0); + const page = analysisViewResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const response = await call("inspect_analysis_view", request); + expect(response.isError).not.toBe(true); + expect( + parseEvidence(response.structuredContent).normalized_result, + ).toEqual(page); + expect(page, test.label).toMatchObject({ + parent_evidence_id: test.evidence.evidence_id, + items: { rows: test.rows }, + coverage: test.coverage, + }); + } + }, +); diff --git a/tests/boundary/android/androidManifestComponents.test.ts b/tests/boundary/android/androidManifestComponents.test.ts index 9d5f64257f..618169cfdd 100644 --- a/tests/boundary/android/androidManifestComponents.test.ts +++ b/tests/boundary/android/androidManifestComponents.test.ts @@ -689,6 +689,60 @@ it("leaves target-31-or-later filtered components without an inferred export def ).toMatchObject({ exported_default: null }); }); +it("ignores action-less activity, alias and receiver filters for export defaults", () => { + // Android's package parser drops these filters; a service keeps them. + const manifest = [ + '', + '', + '', + '', + '', + '', + "", + ].join(""); + const defaults = (sdk: string) => + parseAndroidManifestComponents(manifest, "complete", sdk).items.map( + (item) => [item.intent_filter_count, item.exported_default], + ); + expect(defaults("30")).toEqual([ + [1, false], + [1, false], + [1, false], + [1, true], + [2, true], + ]); + const modern = parseAndroidManifestComponents(manifest, "complete", "31"); + expect(modern.items.map((item) => item.exported_default)).toEqual([ + false, + false, + false, + null, + null, + ]); + expect(modern.limitations.join(" ")).toContain( + "2 component(s) with intent filters target API 31", + ); + expect(defaults("Preview").map(([, value]) => value)).toEqual([ + false, + false, + false, + null, + null, + ]); + const filters = "".repeat( + ANDROID_MANIFEST_LIMITS.intentFilters, + ); + const late = parseAndroidManifestComponents( + `${filters}`, + "complete", + "30", + ); + expect(late.items[0]).toMatchObject({ + intent_filter_count: ANDROID_MANIFEST_LIMITS.intentFilters + 1, + exported_default: true, + }); +}); + it("ignores same-local-name attributes from other namespaces", () => { const manifest = [ '', diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 1f2156ce19..b1e816758b 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -677,6 +677,11 @@ it.runIf(unsupportedHost)( const projected = projectAnalysisError(result.error); expect(projected.message).toContain("Linux x64"); expect(projected.remediation.action).toContain("Linux x64"); + // The refusal names this host, not the target, which was never read. + expect(projected.message).toContain( + `this host is ${process.platform}/${process.arch}`, + ); + expect(projected.message).toContain("The selected target was not read."); }, ); diff --git a/tests/boundary/browser/cdpWebRuntimeReview.test.ts b/tests/boundary/browser/cdpWebRuntimeReview.test.ts index f8243d36e4..f6338ee9d2 100644 --- a/tests/boundary/browser/cdpWebRuntimeReview.test.ts +++ b/tests/boundary/browser/cdpWebRuntimeReview.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it, onTestFinished } from "vitest"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; import { inspectWebEventListenersInputSchema } from "../../../src/domain/webEventListeners.js"; -import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; +import { + negativeColumnScript, + negativeColumnScriptUrl, + startNegativeColumnBrowser, + startRuntimeBrowser, +} from "../../fixtures/webRuntime.js"; describe("runtime document identity across same-document URL changes", () => { it.each(["execution", "listeners"] as const)( @@ -217,3 +222,90 @@ describe("runtime collector failure and uncertain source metadata", () => { ).toBe(false); }); }); + +it("keeps negative columns Chrome reports for an inline script and its first-line callsites", async () => { + const browser = await startNegativeColumnBrowser(); + onTestFinished(() => browser.close()); + const scriptUrl = negativeColumnScriptUrl(browser.endpoint); + const result = await new CdpWebRuntimeProvider().observeExecution( + observeWebExecutionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 30, + }), + { + progress: { + report: async (update) => { + if (update.completed !== 1) return; + for (const [requestId, initiator] of [ + [ + "script-request", + { + type: "script", + stack: { + callFrames: [ + { + scriptId: negativeColumnScript.scriptId, + url: scriptUrl, + functionName: "", + lineNumber: 21, + columnNumber: -40, + }, + ], + }, + }, + ], + [ + "parser-request", + { + type: "parser", + url: scriptUrl, + lineNumber: 21, + columnNumber: -30, + }, + ], + ] as const) + browser.emitEvent({ + method: "Network.requestWillBeSent", + sessionId: "session-1", + params: { + requestId, + frameId: "runtime-main", + timestamp: 12, + request: { + url: `${browser.endpoint}/${requestId}`, + method: "GET", + }, + initiator, + }, + }); + }, + }, + }, + ); + if (!result.ok) throw result.error; + expect( + result.value.requests.map(({ callsites }) => + callsites.map(({ script_id, line_number, column_number }) => ({ + script_id, + line_number, + column_number, + })), + ), + ).toEqual([ + [ + { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -40, + }, + ], + [{ script_id: "", line_number: 21, column_number: -30 }], + ]); + expect(result.value.sources).toContainEqual( + expect.objectContaining({ + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }), + ); +}); diff --git a/tests/boundary/cli/analysisViewCli.test.ts b/tests/boundary/cli/analysisViewCli.test.ts index c0c10c3246..d4b0aac213 100644 --- a/tests/boundary/cli/analysisViewCli.test.ts +++ b/tests/boundary/cli/analysisViewCli.test.ts @@ -719,6 +719,54 @@ cliTest( }, ); +cliTest( + "reads link membership from a deliberately partial portable bundle", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-evidence-links-cli-"); + const path = join(root, "partial.json"); + const absent = analysisViewLayoutEvidence(); + const linked = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "trace_feature", + parameters: {}, + result: {}, + evidenceLinks: [absent.evidence_id], + }, + ); + const bundle = createEvidenceBundle([linked]); + await writeFile(path, JSON.stringify(bundle)); + const options = { + detail: "links", + traversal: "retained-closure", + } as const; + const result = await cli.run({ + arguments: [ + "inspect-evidence-bundle", + JSON.stringify({ path, ...options }), + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(0); + const expected = inspectEvidenceBundle(bundle, options); + if (!expected.ok) throw expected.error; + expect(result.json).toEqual(expected.value); + expect(result.json).toMatchObject({ + kind: "evidence-link-inspection", + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: absent.evidence_id, + status: "missing", + }, + ], + }); + }, +); + cliTest( "formats the full caller-admitted native inventory page as Markdown and JSON", async ({ cli }) => { diff --git a/tests/boundary/cli/cliTimingArguments.test.ts b/tests/boundary/cli/cliTimingArguments.test.ts new file mode 100644 index 0000000000..867e3b5167 --- /dev/null +++ b/tests/boundary/cli/cliTimingArguments.test.ts @@ -0,0 +1,68 @@ +import { it, expect } from "vitest"; +import { Parser, z } from "incur"; +import { + splitCliTimingArguments, + normalizeCliArguments, + splitIncurBuiltinArguments, +} from "../../../src/cliBuiltinArguments.js"; + +it.each([ + [], + ["--timings"], + ["--no-timings"], + ["--timings=false"], + ["--timings=true"], + ["--timings=other"], + ["--timings="], + ["--timings", "--no-timings"], + ["--no-timings", "--timings=true"], + ["--timings=false", "--timings"], + ["--", "--timings"], +])("matches pinned boolean global parsing for %j", (...input: string[]) => { + const actual = splitCliTimingArguments(input); + const parsed = Parser.parseGlobals( + input, + z.object({ timings: z.boolean().default(false) }), + ); + expect(actual).toEqual({ + arguments: parsed.rest, + enabled: parsed.parsed.timings, + }); +}); +it.each([ + "--timings", + "--no-timings", + "--timings=false", + "--timings=true", + "--timings=other", + "--timings=", +])("preserves output values and root alias with %s", (flag) => { + expect(splitCliTimingArguments(["--format", flag])).toEqual({ + arguments: ["--format", flag], + enabled: false, + }); + expect(normalizeCliArguments([flag, "--update", "--json"])).toEqual([ + "update", + flag, + "--json", + ]); + expect(normalizeCliArguments(["--update", flag, "--json"])).toEqual([ + "update", + flag, + "--json", + ]); + expect(splitIncurBuiltinArguments([flag, "mcp", "doctor"]).command).toEqual([ + "mcp", + "doctor", + ]); +}); +it("retains the separator and its following timing-shaped data", () => { + expect(normalizeCliArguments(["--", "--update", "--timings=false"])).toEqual([ + "--", + "--update", + "--timings=false", + ]); + expect(splitCliTimingArguments(["--format", "--", "--timings=true"])).toEqual( + { arguments: ["--format", "--"], enabled: true }, + ); +}); diff --git a/tests/boundary/cli/debuggerOneShotExit.test.ts b/tests/boundary/cli/debuggerOneShotExit.test.ts new file mode 100644 index 0000000000..b5d8b949f4 --- /dev/null +++ b/tests/boundary/cli/debuggerOneShotExit.test.ts @@ -0,0 +1,423 @@ +import { chmod, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { describe, expect, onTestFinished } from "vitest"; + +import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; +import { retainedDebuggerRemediation } from "../../../src/process/ownedDebuggerCleanupError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { + cliProcessEnvironment, + cliTest, +} from "../../support/cli/cliFixture.js"; +import type { + ProcessResult, + TestProcesses, +} from "../../support/process/processFixture.js"; +import { rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js"; + +/** + * Fake debuggers behind the compiled public entrypoint. Real Rizin and GDB are + * not exercised: what they do once REA's end of their pipes closes is unverified. + * Every retained mode stays alive after stdin ends, like a debugger that ignores + * EOF, so a passing test also shows that REA did not stop what it reports retained. + * A request neither fake models is noted, and that note fails the test. + */ +const fakeDebuggerPrelude = `#!${process.execPath} +const { appendFileSync } = require("node:fs"); +const note = (line) => appendFileSync(process.env.FAKE_DEBUGGER_LOG, line + "\\n"); +const mode = process.env.FAKE_DEBUGGER_MODE; +note("pid " + process.pid); +process.on("SIGTERM", () => { note("sigterm"); process.exit(0); }); +process.stdin.setEncoding("utf8"); +process.stdin.on("end", () => note("stdin-end")); +setInterval(() => {}, 1000); +const onLines = (receive) => { + let input = ""; + process.stdin.on("data", (chunk) => { + input += chunk; + for (let end = input.indexOf("\\n"); end >= 0; end = input.indexOf("\\n")) { + receive(input.slice(0, end)); + input = input.slice(end + 1); + } + }); +}; +`; + +const fakeRizin = `${fakeDebuggerPrelude} +process.stdout.write("\\0"); +let silent = false; +onLines((line) => { + if (line === "dr") process.stdout.write("rax=0x1234\\0"); + else if (line === "olj") { + if (silent) return; + note("inventory-request"); + if (mode === "unanswered") silent = true; + else process.stdout.write(mode === "clean" ? "[]\\0" : "not-json\\0"); + } else if (/^!echo __REA_(COMMAND|CLEANUP)_COMPLETE_[0-9a-f]+__$/u.test(line)) { + if (!silent) process.stdout.write(line.slice(6) + "\\n\\0"); + } else { + note("unmodeled " + line); + process.stdout.write("unmodeled\\0"); + } +}); +`; + +const fakeGdb = `${fakeDebuggerPrelude} +process.stdout.write("(gdb)\\n"); +let tty = ""; +let inventoryRequests = 0; +let ttyReads = 0; +const liveGroup = '^done,groups=[{id="i1",type="process",pid="4194305",threads=[{id="1",state="stopped"}]}]'; +onLines((line) => { + const [, token, request] = /^(\\d+)(.*)$/u.exec(line) ?? []; + const reply = (tail) => process.stdout.write(token + tail + "\\n(gdb)\\n"); + if (request === "-gdb-set auto-load off") reply("^done"); + else if (request?.startsWith("-inferior-tty-set ")) { + tty = JSON.parse(request.slice("-inferior-tty-set ".length)); + reply("^done"); + } else if (request === "-inferior-tty-show") { + ttyReads += 1; + reply('^done,inferior_tty_terminal="' + (ttyReads > 1 ? "/dev/null" : tty) + '"'); + } else if (request === '-interpreter-exec console "show version"') { + process.stdout.write('~"GNU gdb (fake)\\\\n"\\n'); + reply("^done"); + } else if (request === "-list-thread-groups --recurse 1") { + inventoryRequests += 1; + if (mode === "clean") reply("^done,groups=[]"); + else if (mode === "malformed") reply('^error,msg="unavailable"'); + else if (inventoryRequests <= 2) reply(liveGroup); + else note("inventory-request"); + } else if (request === "-gdb-exit") { + process.stdout.write(token + "^exit\\n"); + process.exit(0); + } else { + note("unmodeled " + line); + if (token !== undefined) reply('^error,msg="unmodeled request"'); + } +}); +`; + +const debuggers = { + rizin: { + environmentKey: "REA_RIZIN_COMMAND", + source: fakeRizin, + arguments: (tty: string) => [ + "debug-with-rizin", + process.execPath, + "dr", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "rizin_debug_command", + startTool: { + name: "start_rizin_debug_session", + arguments: (tty: string) => ({ + path: process.execPath, + inferior_tty: tty, + }), + }, + }, + gdb: { + environmentKey: "REA_GDB_COMMAND", + source: fakeGdb, + arguments: (tty: string) => [ + "debug-with-gdb", + "show version", + "--inferior-tty", + tty, + "--format", + "json", + ], + operation: "gdb_console", + startTool: { + name: "start_gdb_session", + arguments: (tty: string) => ({ inferior_tty: tty }), + }, + }, +} as const; + +type DebuggerName = keyof typeof debuggers; +type FakeMode = "clean" | "malformed" | "unanswered"; + +const EXIT_DEADLINE_MS = 20_000; +const TEST_DEADLINE_MS = 2 * EXIT_DEADLINE_MS; + +const isAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +/** Launch the compiled CLI against one fake debugger, on any POSIX host. */ +const prepareLaunch = async ( + name: DebuggerName, + mode: FakeMode, + compileCache: string, +) => { + const root = await createTestTempDirectory("rea-debugger-one-shot-exit-"); + const log = join(root, "debugger.log"); + await writeFile(log, ""); + const provider = join(root, name); + await writeFile(provider, debuggers[name].source); + await chmod(provider, 0o755); + // Persistent debugger sessions are Linux-only behind this gate. These modes + // stop before any /proc read, so the same workflow also runs on macOS. + const linuxGate = join(root, "linux-gate.mjs"); + await writeFile( + linuxGate, + 'Object.defineProperty(process, "platform", { value: "linux" });\n', + ); + const notes = async (): Promise => + (await readFile(log, "utf8")).split("\n").filter(Boolean); + const debuggerPid = async (): Promise => { + const line = (await notes()).find((note) => note.startsWith("pid ")); + if (line === undefined) throw new Error("The fake debugger did not start"); + return Number(line.slice("pid ".length)); + }; + onTestFinished(async () => { + const pid = await debuggerPid().catch(() => undefined); + if (pid !== undefined && isAlive(pid)) process.kill(pid, "SIGKILL"); + expect( + (await notes()).filter((note) => note.startsWith("unmodeled ")), + ).toEqual([]); + }); + return { + notes, + debuggerPid, + command: [ + "--import", + linuxGate, + resolve("scripts/rea.mjs"), + ] as readonly string[], + env: cliProcessEnvironment( + { + [debuggers[name].environmentKey]: provider, + FAKE_DEBUGGER_LOG: log, + FAKE_DEBUGGER_MODE: mode, + }, + compileCache, + ), + }; +}; + +/** Comparing with the formatter alone would still pass if it dropped the process. */ +const expectNamesProcess = (guidance: string, pid: number): void => { + expect(guidance).toMatch(new RegExp(`\\b${String(pid)}\\b`, "u")); +}; + +const expectRetainedOutcome = ( + name: DebuggerName, + result: ProcessResult, + pid: number, +): void => { + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + remediation: { action: retainedDebuggerRemediation([pid]) }, + details: { + operation: debuggers[name].operation, + cleanup: "incomplete", + partial_observation: { + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }, + }, + }); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + expect(result.stdout).toContain(`"retained_debugger_pids":[${String(pid)}]`); +}; + +/** Serve one MCP stdio client that has started a debugger session. */ +const startMcpSession = async ( + processes: TestProcesses, + name: DebuggerName, + launch: Awaited>, + tty: string, +) => { + const child = processes.spawn(process.execPath, [...launch.command, "mcp"], { + env: launch.env, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stderr.on("data", (chunk: Buffer) => (stderr += String(chunk))); + const request = async ( + id: number, + method: string, + params: object, + ): Promise => { + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, + ); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain(`"id":${String(id)}`); + }; + await request(1, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "debugger-one-shot-exit", version: "1" }, + }); + child.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`, + ); + const { startTool } = debuggers[name]; + await request(2, "tools/call", { + name: startTool.name, + arguments: startTool.arguments(tty), + }); + expect(stdout).toContain("session_id"); + return { child, stderr: () => stderr }; +}; + +describe.skipIf(process.platform === "win32")( + "debugger processes that outlive their cleanup", + () => { + cliTest.for([ + ["rizin", "malformed"], + ["rizin", "unanswered"], + ["gdb", "malformed"], + ["gdb", "unanswered"], + ] as const)( + "ends one-shot %s with a retained debugger whose cleanup is %s", + { timeout: TEST_DEADLINE_MS }, + async ([name, mode], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, mode, cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 1, signal: null }); + const pid = await launch.debuggerPid(); + expectRetainedOutcome(name, result, pid); + // REA reports the debugger as retained and did not stop it. Exiting + // closes its command stream; that is the only thing it sees from REA. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("stdin-end"); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "keeps a one-shot %s success and its exit status when cleanup is confirmed", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "clean", cliCompileCache); + + const result = await processes.run( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env, timeoutMs: EXIT_DEADLINE_MS }, + ); + + expect(result).toMatchObject({ exitCode: 0, signal: null }); + expect(JSON.parse(result.stdout)).toMatchObject({ + operation: debuggers[name].operation, + normalized_result: { completion_status: "complete" }, + }); + expect(result.stdout).not.toContain("retained_debugger_pids"); + const pid = await launch.debuggerPid(); + await expect + .poll(() => isAlive(pid), { timeout: EXIT_DEADLINE_MS }) + .toBe(false); + }, + ); + + cliTest.for(["rizin", "gdb"] as const)( + "ends one-shot %s with its signal status when interrupted during final cleanup", + { timeout: TEST_DEADLINE_MS }, + async (name, { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "unanswered", cliCompileCache); + + const child = processes.spawn( + process.execPath, + [...launch.command, ...debuggers[name].arguments(tty)], + { env: launch.env }, + ); + let stdout = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk))); + child.stdin.end(); + // The unanswered request is issued by final cleanup, after the command. + await expect + .poll(launch.notes, { timeout: EXIT_DEADLINE_MS }) + .toContain("inventory-request"); + child.kill("SIGINT"); + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(130); + await expect + .poll(() => stdout, { timeout: EXIT_DEADLINE_MS }) + .toContain("retained_debugger_pids"); + + const pid = await launch.debuggerPid(); + expectRetainedOutcome( + name, + { exitCode: 130, signal: null, stdout, stderr: "" }, + pid, + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + + cliTest.for([ + ["rizin", "client disconnect"], + ["gdb", "client disconnect"], + ["rizin", "SIGTERM"], + ] as const)( + "ends the stdio MCP server holding a retained %s session on %s and names it", + { timeout: TEST_DEADLINE_MS }, + async ([name, trigger], { processes, cliCompileCache }) => { + const tty = await rizinTestInferiorTty(); + const launch = await prepareLaunch(name, "malformed", cliCompileCache); + + const { child, stderr } = await startMcpSession( + processes, + name, + launch, + tty, + ); + + if (trigger === "SIGTERM") child.kill("SIGTERM"); + else child.stdin.end(); + + await expect + .poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS }) + .toBe(1); + const pid = await launch.debuggerPid(); + // Nothing else reports these once the server has exited. + await expect + .poll(stderr, { timeout: EXIT_DEADLINE_MS }) + .toContain(`${retainedDebuggerRemediation([pid])}\n`); + expectNamesProcess(retainedDebuggerRemediation([pid]), pid); + const logs = stderr() + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line): unknown => JSON.parse(line)); + for (const msg of [ + "Provider cleanup failed during server shutdown", + MCP_SHUTDOWN_FAILED, + ]) + expect(logs).toContainEqual( + expect.objectContaining({ msg, retained_debugger_pids: [pid] }), + ); + expect(await launch.notes()).not.toContain("sigterm"); + expect(isAlive(pid)).toBe(true); + }, + ); + }, +); diff --git a/tests/boundary/cli/defaultAmpRegistration.test.ts b/tests/boundary/cli/defaultAmpRegistration.test.ts new file mode 100644 index 0000000000..e52271e21a --- /dev/null +++ b/tests/boundary/cli/defaultAmpRegistration.test.ts @@ -0,0 +1,377 @@ +import { + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { delimiter, join } from "node:path"; +import { expect, it } from "vitest"; +import { Parser, z } from "incur"; + +import { splitIncurBuiltinArguments } from "../../../src/cliBuiltinArguments.js"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const fixture = async (initial: string) => { + const home = await createTestTempDirectory("rea-default-amp-"); + const bin = join(home, "bin"); + const log = join(home, "runner.jsonl"); + const script = join(home, "runner.cjs"); + const directory = join(home, ".config", "amp"); + const settings = join(directory, "settings.json"); + await mkdir(bin); + await mkdir(directory, { recursive: true }); + await writeFile(settings, initial); + await writeFile(log, ""); + await writeFile( + script, + `require("node:fs").appendFileSync(process.env.REA_TEST_RUNNER_LOG, +JSON.stringify(process.argv.slice(2)) + "\\n"); +if (process.env.REA_TEST_RUNNER_FAIL) { + console.error("fixture runner failure"); + process.exitCode = 23; +} else console.log("│ ✓ Fixture Client: fixture │"); +`, + ); + for (const runner of ["npx", "bun", "bunx", "pnpx"]) + await writeFile( + join(bin, process.platform === "win32" ? `${runner}.cmd` : runner), + process.platform === "win32" + ? `@echo off\r\n"%REA_TEST_NODE%" "%REA_TEST_RUNNER_SCRIPT%" %*\r\n` + : `#!/bin/sh\nexec "$REA_TEST_NODE" "$REA_TEST_RUNNER_SCRIPT" "$@"\n`, + { mode: 0o700 }, + ); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + PATH: [bin, process.env.PATH ?? ""].join(delimiter), + REA_TEST_NODE: process.execPath, + REA_TEST_RUNNER_SCRIPT: script, + REA_TEST_RUNNER_LOG: log, + npm_config_user_agent: "npm/11 node/v24.22.1", + npm_execpath: "", + }; + for (const key of [ + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "XDG_STATE_HOME", + "TMPDIR", + ]) { + environment[key] = join(home, key); + await mkdir(environment[key]!); + } + return { home, log, directory, settings, environment }; +}; + +const original = `${JSON.stringify({ + "amp.mcpServers": { + rea: { command: "old", env: { KEEP_ME: "1" } }, + peer: { command: "peer" }, + }, + theme: "dark", +})}\n`; + +cliTest( + "default registration preserves Amp settings and recovery in both output modes", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await readFile(f.settings, "utf8"); + const result = await cli.run({ + arguments: [ + "mcp", + "add", + "--command", + "fixture-server --mcp", + ...output, + ], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("Amp"); + expect(JSON.parse(await readFile(f.settings, "utf8"))).toEqual({ + "amp.mcpServers": { + rea: { + command: "fixture-server", + args: ["--mcp"], + env: { KEEP_ME: "1" }, + }, + peer: { command: "peer" }, + }, + theme: "dark", + }); + const backup = result.stderr.trim().split(" retained at ")[1]!; + expect(await readFile(backup, "utf8")).toBe(before); + if (process.platform !== "win32") + expect((await stat(backup)).mode & 0o777).toBe(0o600); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe( + original, + ); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls.map((line) => JSON.parse(line))).toEqual( + Array.from({ length: 2 }, () => [ + "add-mcp", + "fixture-server --mcp", + "--name", + "rea", + "-y", + "-g", + ]), + ); + } + }, +); + +cliTest( + "default registration refuses invalid settings and unavailable backups before the runner", + async ({ cli }) => { + for (const initial of [ + "broken JSON\n", + "null\n", + '{"amp.mcpServers":[]}\n', + original, + ]) { + const f = await fixture(initial); + if (initial === original) await mkdir(`${f.settings}.rea.backup`); + for (const output of [[], ["--json"]]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("VALIDATION_ERROR"); + if (output.length > 0) + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + }); + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readdir(f.directory)).toEqual( + initial === original + ? ["settings.json", "settings.json.rea.backup"] + : ["settings.json"], + ); + } + } + }, +); + +cliTest( + "non-Amp selections, command values and discovery do not touch Amp settings", + async ({ cli }) => { + const f = await fixture("broken JSON\n"); + const selections = [ + ["--agent", "claude-code"], + ["--agent=claude-code"], + ["--agent", "cursor", "--agent", "claude-code"], + ]; + for (const selection of selections) { + const result = await cli.run({ + arguments: ["mcp", "add", ...selection, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + // A following flag is not a command value. Reject it before Amp settings. + for (const selection of [ + ["--command", "--agent", "amp", "--agent", "claude-code"], + ["-c", "--agent", "amp", "--agent", "claude-code"], + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", ...selection, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(1); + expect(result.stdout).toContain("Missing value for flag:"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(selections.length); + for (const line of calls) expect(JSON.parse(line)).toContain("claude-code"); + await writeFile(f.log, ""); + for (const flag of [ + "--help", + "-h", + "--llms", + "--llms-full", + "--schema", + "--version", + ]) { + const result = await cli.run({ + arguments: ["mcp", "add", flag, "--json"], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(flag === "--schema" ? 1 : 0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + expect(await readFile(f.log, "utf8")).toBe(""); + }, +); + +cliTest( + "default runner failure retains the original settings and failure status", + async ({ cli }) => { + for (const output of [[], ["--json"]]) { + const f = await fixture(original); + const result = await cli.run({ + arguments: ["mcp", "add", ...output], + cwd: f.home, + environment: { ...f.environment, REA_TEST_RUNNER_FAIL: "1" }, + timeoutMs: 20_000, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("MCP_ADD_FAILED"); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stderr).not.toContain("backup retained"); + expect(await readFile(f.settings, "utf8")).toBe(original); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + expect((await readFile(f.log, "utf8")).trim().split("\n")).toHaveLength( + 1, + ); + } + }, +); + +cliTest( + "timing globals cannot bypass Amp preservation or validation", + async ({ cli }) => { + const cases = [ + ["--no-timings", "mcp", "add"], + ["--timings=false", "mcp", "add", "--json"], + ["mcp", "--timings=true", "add"], + ["mcp", "add", "--agent", "--no-timings", "amp", "--json"], + ["mcp", "add", "--agent", "--timings=false", "amp"], + ]; + for (const arguments_ of cases) { + const f = await fixture(original); + const run = () => + cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + const registered = await run(); + expect(registered.exitCode, registered.stdout).toBe(0); + expect( + JSON.parse(await readFile(f.settings, "utf8"))["amp.mcpServers"].rea + .env, + ).toEqual({ KEEP_ME: "1" }); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + await writeFile(f.settings, "broken JSON\n"); + await writeFile(f.log, ""); + const rejected = await run(); + expect(rejected.exitCode).toBe(1); + expect(rejected.stdout).toContain("VALIDATION_ERROR"); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readFile(f.log, "utf8")).toBe(""); + expect(await readFile(`${f.settings}.rea.backup`, "utf8")).toBe(original); + } + const f = await fixture("broken JSON\n"); + for (const arguments_ of [ + [ + "--no-timings", + "mcp", + "add", + "--agent", + "--timings=false", + "claude-code", + ], + ["--timings=false", "mcp", "add", "--help"], + ]) { + const result = await cli.run({ + arguments: arguments_, + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(await readFile(f.settings, "utf8")).toBe("broken JSON\n"); + expect(await readdir(f.directory)).toEqual(["settings.json"]); + } + const calls = (await readFile(f.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]!)).toContain("claude-code"); + }, +); + +it("timing splitting matches Incur after output values and before the terminator", () => { + for (const flag of [ + "--timings", + "--no-timings", + "--timings=false", + "--timings=true", + "--timings=other", + ]) { + for (const command of [ + [flag, "mcp", "add"], + ["mcp", "add", "--agent", flag, "amp"], + ["mcp", "add", "--command", flag], + ["mcp", "add", "--", flag], + ]) { + const expected = Parser.parseGlobals( + command, + z.object({ timings: z.boolean().default(false) }), + undefined, + { validate: false }, + ).rest; + expect(splitIncurBuiltinArguments(command).command).toEqual(expected); + // The builtin output pass consumes this flag-shaped value before globals. + expect( + splitIncurBuiltinArguments(["--filter-output", flag, ...command]) + .command, + ).toEqual(expected); + } + } +}); + +cliTest( + "expanded discovery values never inspect or back up Amp settings", + async ({ cli }) => { + for (const initial of [undefined, "broken JSON\n", original]) { + const f = await fixture(initial ?? ""); + if (initial === undefined) await rm(f.settings); + for (const flag of ["--help", "--llms"]) { + const result = await cli.run({ + arguments: ["mcp", "add", `--command=${flag}`], + cwd: f.home, + environment: f.environment, + timeoutMs: 20_000, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.stdout).not.toContain("VALIDATION_ERROR"); + expect(result.stdout).not.toContain("Registered rea"); + expect(result.stdout.length).toBeGreaterThan(0); + expect(result.stderr).not.toContain("backup retained"); + expect(await readdir(f.directory)).toEqual( + initial === undefined ? [] : ["settings.json"], + ); + if (initial !== undefined) + expect(await readFile(f.settings, "utf8")).toBe(initial); + expect(await readFile(f.log, "utf8")).toBe(""); + } + } + }, +); diff --git a/tests/boundary/cli/delimitedAnalysisViewCli.test.ts b/tests/boundary/cli/delimitedAnalysisViewCli.test.ts index f83202a18a..e2f8e377b6 100644 --- a/tests/boundary/cli/delimitedAnalysisViewCli.test.ts +++ b/tests/boundary/cli/delimitedAnalysisViewCli.test.ts @@ -7,6 +7,20 @@ import { ARTIFACT_COMPARISON_EXAMPLE } from "../../../src/contracts/artifactComp import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +cliTest( + "shows how to export inventory rows in command help", + async ({ cli }) => { + const result = await cli.run({ + arguments: ["inspect-analysis-view", "--help"], + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--format csv"); + expect(result.stdout).toContain("--format tsv"); + expect(result.stdout).toContain("--filter-output normalized_result.items"); + expect(result.stdout).toContain("--columns logical_path,artifact_id"); + }, +); + cliTest( "exports selected artifact inventory columns through the public CLI", async ({ cli }) => { diff --git a/tests/boundary/cli/doctorRegistrationEnvironment.test.ts b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts new file mode 100644 index 0000000000..888c6e186d --- /dev/null +++ b/tests/boundary/cli/doctorRegistrationEnvironment.test.ts @@ -0,0 +1,289 @@ +import { chmod, mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +/** An installation doctor admits; nothing here starts an analysis JVM. */ +const writeAdmittedGhidra = async (root: string) => { + const jdk = join(root, "jdk"); + const install = join(root, "ghidra"); + const native = join( + install, + "Ghidra/Features/Decompiler/os", + `${process.platform === "darwin" ? "mac" : "linux"}_${process.arch === "arm64" ? "arm_64" : "x86_64"}`, + "decompile", + ); + for (const path of [ + join(jdk, "bin"), + join(install, "support"), + join(native, ".."), + ]) + await mkdir(path, { recursive: true }); + await writeFile( + join(install, "Ghidra/application.properties"), + "application.version=12.1.4\napplication.java.min=21\n", + ); + await writeFile( + join(install, "support/analyzeHeadless"), + "#!/bin/sh\nexit 99\n", + { mode: 0o700 }, + ); + await writeFile( + native, + process.platform === "darwin" + ? thinMach(0xfeedfacf, process.arch === "arm64" ? 0x0100000c : 0x01000007) + : elf(2, 1, process.arch === "arm64" ? 183 : 62), + ); + await chmod(native, 0o700); + const properties = [ + "java.version = 21.0.11", + `java.home = ${jdk}`, + "sun.arch.data.model = 64", + `os.arch = ${process.arch === "arm64" ? "aarch64" : "amd64"}`, + `os.name = ${process.platform === "darwin" ? "Mac OS X" : "Linux"}`, + "", + ].join("\n"); + await writeFile( + join(jdk, "bin/java"), + `#!${process.execPath}\nprocess.stderr.write(${JSON.stringify(properties)});\n`, + { mode: 0o700 }, + ); + await writeFile(join(jdk, "bin/javac"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + return { install, jdk }; +}; + +const reportSchema = z.object({ + healthy: z.boolean(), + scope_checks: z.array(z.object({ name: z.string() }).loose()), + informational_checks: z.array(z.object({ name: z.string() }).loose()), + identity: z.object({ + registrations: z.array(z.object({ client: z.string() }).loose()), + }), +}); + +const run = async ( + cli: TestCli, + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const result = await cli.run({ + arguments: [...arguments_, "--json"], + environment, + timeoutMs: 30_000, + }); + return { result, report: reportSchema.safeParse(result.json) }; +}; + +const doctor = async ( + cli: TestCli, + scope: readonly string[], + environment: NodeJS.ProcessEnv, +) => { + const { result, report } = await run(cli, ["doctor", ...scope], environment); + if (!report.success) throw new Error(result.stdout + result.stderr); + return { exitCode: result.exitCode, report: report.data }; +}; + +const names = (checks: readonly { readonly name: string }[]) => + checks.map(({ name }) => name); + +const CHECK = "registration_environment:claude_code"; + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor does not let a configured terminal stand in for an unconfigured registration", + { timeout: 120_000 }, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-env-"); + const home = join(root, "home"); + await mkdir(home); + const { install, jdk } = await writeAdmittedGhidra(root); + const clientEnvironment = { + HOME: home, + USERPROFILE: home, + // The host's own Ghidra or Java must not satisfy this fixture. + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + const terminal = { + ...clientEnvironment, + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }; + const configPath = join(home, ".claude.json"); + const saved = async () => + z + .object({ + mcpServers: z.object({ + rea: z.object({ env: z.record(z.string(), z.string()) }), + }), + }) + .parse(JSON.parse(await readFile(configPath, "utf8"))).mcpServers.rea + .env; + + // Setup ran where the provider settings were not visible. + const unconfigured = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + clientEnvironment, + ); + expect(unconfigured.result.exitCode, unconfigured.result.stdout).toBe(0); + expect(await saved()).not.toHaveProperty("GHIDRA_INSTALL_DIR"); + + // The registration's own environment is what a client without these + // settings gives its server; it does not reach the terminal's result. + const served = await doctor(cli, ["--provider", "ghidra"], { + ...(await saved()), + HOME: home, + USERPROFILE: home, + PATH: clientEnvironment.PATH, + }); + expect(served.exitCode).toBe(1); + expect(served.report.scope_checks).toContainEqual( + expect.objectContaining({ + name: "ghidra", + ok: false, + classification: "missing_analysis_engine", + }), + ); + expect(names(served.report.scope_checks)).not.toContain(CHECK); + + for (const scope of [ + ["--provider", "ghidra"], + ["--client", "claude_code"], + ["--provider", "ghidra", "--client", "claude_code"], + ]) { + const scoped = await doctor(cli, scope, terminal); + expect(scoped.report.scope_checks, scope.join(" ")).toContainEqual( + expect.objectContaining({ + name: CHECK, + ok: true, + classification: "unknown", + detail: expect.stringContaining( + "does not record GHIDRA_INSTALL_DIR, JAVA_HOME", + ), + details: { + effective_environment: "unknown", + providers: ["ghidra"], + }, + remediation: expect.stringContaining( + "rea setup --client claude_code --skill=false --dry-run", + ), + }), + ); + // An unobserved environment is neither a failure nor a healthy match. + expect(scoped.report.healthy, scope.join(" ")).toBe(true); + expect(scoped.exitCode, scope.join(" ")).toBe(0); + expect(scoped.report.identity.registrations).toEqual([ + expect.objectContaining({ + client: "claude_code", + state: "aligned", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + }), + ]); + } + + const unrelated = await doctor(cli, ["--skill"], terminal); + expect(names(unrelated.report.scope_checks)).not.toContain(CHECK); + expect(names(unrelated.report.informational_checks)).toContain(CHECK); + + // Doctor reads; the configuration is unchanged until setup is approved. + const before = await readFile(configPath, "utf8"); + await doctor(cli, [], terminal); + expect(await readFile(configPath, "utf8")).toBe(before); + + // The named remediation records exactly these settings and then clears it. + const planned = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--dry-run"], + terminal, + ); + expect(planned.result.stdout).toContain(`GHIDRA_INSTALL_DIR=${install}`); + expect(planned.result.stdout).toContain(`JAVA_HOME=${jdk}`); + expect(await readFile(configPath, "utf8")).toBe(before); + const applied = await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + terminal, + ); + expect(applied.result.exitCode, applied.result.stdout).toBe(0); + expect(await saved()).toMatchObject({ + GHIDRA_INSTALL_DIR: install, + JAVA_HOME: jdk, + }); + expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(before); + + const recorded = await doctor(cli, ["--provider", "ghidra"], terminal); + expect(recorded.exitCode).toBe(0); + expect(names(recorded.report.scope_checks)).not.toContain(CHECK); + expect(recorded.report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + + // Another terminal selecting a different JDK spelling is reported as that. + const alias = join(root, "jdk-alias"); + await symlink(jdk, alias); + const otherTerminal = { ...terminal, JAVA_HOME: alias }; + const other = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(other.report.identity.registrations[0]).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: [], + }, + }); + + // Setup's own spelling of a caller selection compares as recorded. + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + otherTerminal, + ); + expect(await saved()).toMatchObject({ JAVA_HOME: alias }); + const aliased = await doctor(cli, ["--provider", "ghidra"], otherTerminal); + expect(names(aliased.report.scope_checks)).not.toContain(CHECK); + }, +); + +cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( + "doctor adds nothing when the terminal's provider is not available", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-doctor-registration-off-"); + const home = join(root, "home"); + await mkdir(home); + const environment = { + HOME: home, + USERPROFILE: home, + PATH: "/usr/bin:/bin", + HOPPER_LAUNCHER_PATH: join(root, "missing-hopper"), + REA_LOG_LEVEL: "silent", + }; + await run( + cli, + ["setup", "--client", "claude_code", "--skill=false", "--yes"], + environment, + ); + // A provider setting that fails its own check has no green result to + // misread; that failure is the report. + const { report } = await doctor(cli, ["--provider", "ghidra"], { + ...environment, + GHIDRA_INSTALL_DIR: join(root, "missing-ghidra"), + }); + expect(report.healthy).toBe(false); + expect(names(report.scope_checks)).not.toContain(CHECK); + expect(names(report.informational_checks)).not.toContain(CHECK); + expect(report.identity.registrations[0]).not.toHaveProperty( + "provider_settings", + ); + }, +); diff --git a/tests/boundary/cli/emptyArtifactBundleTargetCli.test.ts b/tests/boundary/cli/emptyArtifactBundleTargetCli.test.ts new file mode 100644 index 0000000000..50b8aeac86 --- /dev/null +++ b/tests/boundary/cli/emptyArtifactBundleTargetCli.test.ts @@ -0,0 +1,119 @@ +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build } from "plist"; +import { expect } from "vitest"; + +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const createWorkingBundleFixture = async (prefix: string) => { + const root = await createTestTempDirectory(prefix); + const bundle = join(root, "Example.app"); + const temp = join(root, "temp"); + const contents = join(bundle, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await mkdir(join(contents, "Resources"), { recursive: true }); + await mkdir(temp, { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + build({ CFBundleExecutable: "Example" }), + ); + await writeFile( + join(contents, "MacOS", "Example"), + thinMach(0xcffaedfe, 0x0100000c), + ); + await writeFile( + join(contents, "Resources", "Archive.plist"), + build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "resource-only-value"], + $top: { root: { CF$UID: 1 } }, + }), + ); + const environment = { + HOME: join(root, "home"), + USERPROFILE: join(root, "home"), + TMPDIR: temp, + TMP: temp, + TEMP: temp, + REA_LOG_LEVEL: "silent", + }; + return { bundle, temp, environment }; +}; + +cliTest( + "compiled CLI rejects empty inspect-keyed-archive target in a bundle cwd while preserving explicit dot", + async ({ cli }) => { + const { bundle, environment } = await createWorkingBundleFixture( + "rea-empty-keyed-bundle-", + ); + const archive = "Contents/Resources/Archive.plist"; + const empty = await cli.run({ + arguments: ["inspect-keyed-archive", "", archive, "--json"], + cwd: bundle, + environment, + }); + expect(empty.exitCode).not.toBe(0); + expect(empty.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ path: "path", code: "too_small" }), + ], + }); + + const explicitDot = await cli.run({ + arguments: ["inspect-keyed-archive", ".", archive, "--json"], + cwd: bundle, + environment, + }); + expect(explicitDot.exitCode, explicitDot.stderr).toBe(0); + const evidence = parseEvidence(explicitDot.json); + expect(evidence.parameters).toMatchObject({ input_path: "." }); + expect(evidence.normalized_result).toMatchObject({ + total_objects: 2, + archive_path: archive, + }); + }, +); + +cliTest( + "compiled CLI rejects empty extract-artifact and bundle artifact targets before touching the working directory", + async ({ cli }) => { + const { bundle, temp, environment } = await createWorkingBundleFixture( + "rea-empty-artifact-bundle-", + ); + for (const command of [ + "extract-artifact", + "decode-interface-builder", + "inspect-asset-catalog", + "trace-dylib-resolution", + ]) { + const empty = await cli.run({ + arguments: [command, "", "--json"], + cwd: bundle, + environment, + }); + expect(empty.exitCode, command).not.toBe(0); + expect(empty.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ path: "path", code: "too_small" }), + ], + }); + } + expect(await readdir(temp)).toEqual([]); + + const explicitExtract = await cli.run({ + arguments: ["extract-artifact", ".", "--json"], + cwd: bundle, + environment, + }); + expect(explicitExtract.exitCode, explicitExtract.stderr).toBe(0); + expect(parseEvidence(explicitExtract.json).parameters).toMatchObject({ + input_path: ".", + }); + }, +); diff --git a/tests/boundary/cli/emptyLocalTargetAdmission.test.ts b/tests/boundary/cli/emptyLocalTargetAdmission.test.ts index 4e28416a72..9d288516da 100644 --- a/tests/boundary/cli/emptyLocalTargetAdmission.test.ts +++ b/tests/boundary/cli/emptyLocalTargetAdmission.test.ts @@ -1,5 +1,6 @@ import { Cli } from "incur"; import { expect, it, vi } from "vitest"; +import { registerArtifactCommands } from "../../../src/cli/artifactCommands.js"; import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js"; import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; import { registerManagedCommands } from "../../../src/cli/managedCommands.js"; @@ -16,6 +17,7 @@ const registry = () => { const runDirectAnalysis = run(); const runProviderAnalysis = run(); const cli = Cli.create("empty-target-admission", { sync: false }); + registerArtifactCommands(cli, silentLogger, runProviderAnalysis); registerCoreBinaryCommands(cli, silentLogger, runDirectAnalysis, {}); registerCoreNativeCommands(cli, silentLogger, runDirectAnalysis); registerManagedCommands(cli, silentLogger, runProviderAnalysis); @@ -44,6 +46,12 @@ const registry = () => { // Each command with its other required arguments; the target comes first. const commands: readonly (readonly string[])[] = [ + ["inspect-artifact"], + ["extract-artifact"], + ["inspect-keyed-archive"], + ["trace-dylib-resolution"], + ["inspect-asset-catalog"], + ["decode-interface-builder"], ["inspect"], ["decompile", "main"], ["xrefs", "0x1000"], @@ -83,7 +91,7 @@ const commands: readonly (readonly string[])[] = [ ]; it.each(commands)( - "rejects an empty %s target before resolution and admits explicit .", + "rejects an empty or NUL %s target before resolution and admits explicit .", async (command, ...rest) => { const { serve, runs } = registry(); // docs/cli.md: an empty string never selects the working directory. @@ -93,6 +101,12 @@ it.each(commands)( expect.objectContaining({ path: "path", code: "too_small" }), ], }); + expect(await serve([command, "target\x00name", ...rest])).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [ + expect.objectContaining({ path: "path", code: "invalid_format" }), + ], + }); for (const run of runs) expect(run).not.toHaveBeenCalled(); await serve([command, ".", ...rest]); expect(runs.flatMap((run) => run.mock.calls.map(([path]) => path))).toEqual( diff --git a/tests/boundary/cli/environmentCompareCli.test.ts b/tests/boundary/cli/environmentCompareCli.test.ts index b7776f3ac9..0113e4b1a0 100644 --- a/tests/boundary/cli/environmentCompareCli.test.ts +++ b/tests/boundary/cli/environmentCompareCli.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; +import type { CompareReport } from "../../../src/application/environment/environmentCompare.js"; import { analysisViewCaptureEvidence, environmentManifestCaptureFixture, @@ -28,6 +29,140 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +describe("rea environment-compare difference coverage", () => { + cliTest( + "discloses the bounded differences in ordinary and full output", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-environment-compare-coverage-", + ); + const counts = [0, 19, 20, 21, 25]; + const originals = new Map(); + for (const [name, value] of [ + ["capture", 0], + ["probe", 1], + ] as const) { + const entries = counts.map((count, index) => { + const text = JSON.stringify( + Object.fromEntries( + Array.from({ length: count }, (_, field) => [ + `field${String(field).padStart(2, "0")}`, + value, + ]), + ), + ); + return { + startedDateTime: new Date( + Date.parse("2026-10-27T20:00:00Z") + index * 1000, + ).toISOString(), + time: 0, + cache: {}, + timings: { send: 0, wait: 0, receive: 0 }, + request: { + method: "GET", + url: `https://app.example/api/config${String(count)}`, + httpVersion: "HTTP/1.1", + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: 0, + }, + response: { + status: 200, + statusText: "OK", + httpVersion: "HTTP/1.1", + headers: [], + cookies: [], + redirectURL: "", + headersSize: -1, + bodySize: -1, + content: { + mimeType: "application/json", + size: Buffer.byteLength(text), + text, + }, + }, + }; + }); + const har = join(root, `${name}.har`); + const text = JSON.stringify({ + log: { + version: "1.2", + creator: { name: "coverage-test", version: "1" }, + entries, + }, + }); + await writeFile(har, text); + originals.set(har, text); + const captured = await cli.run({ + arguments: ["inspect-web-network-capture", har, "har", "--json"], + }); + expect(captured.exitCode, captured.stderr).toBe(0); + const path = join(root, `${name}.json`); + const evidence = JSON.stringify(captured.json); + await writeFile(path, evidence); + originals.set(path, evidence); + } + const view = join(root, "view.json"); + await writeFile( + view, + JSON.stringify({ + source: { + kind: "inline", + evidence: JSON.parse(originals.get(join(root, "capture.json"))!), + }, + view: { kind: "environment-manifest" }, + }), + ); + const projected = await cli.run({ + arguments: ["inspect-analysis-view", view, "--json"], + }); + expect(projected.exitCode, projected.stderr).toBe(0); + const manifest = join(root, "manifest.json"); + const manifestText = JSON.stringify(projected.json); + await writeFile(manifest, manifestText); + originals.set(manifest, manifestText); + const args = [ + "environment-compare", + manifest, + "--evidence", + join(root, "capture.json"), + "--probe", + join(root, "probe.json"), + "--json", + ]; + const result = await cli.run({ arguments: args }); + expect(result.exitCode, result.stderr).toBe(0); + const report = result.json as CompareReport; + expect(report).toMatchObject({ + status: "complete", + summary: { steps: 5, equal: 1, categories: { value: 4 } }, + }); + expect(report.probes[0]!.results).toHaveLength(counts.length); + for (const [index, count] of counts.entries()) { + const step = report.probes[0]!.results[index]!; + expect(step).toMatchObject({ + equal: count === 0, + equal_ignoring_order: count === 0, + categories: count === 0 ? [] : ["value"], + differences_total: count, + differences_omitted: Math.max(0, count - 20), + differences_truncated: count > 20, + }); + expect(step.differences).toHaveLength(Math.min(count, 20)); + if (count >= 20) + expect(step.differences.at(-1)?.pointer).toBe("/field19"); + } + const full = await cli.run({ arguments: [...args, "--full-output"] }); + expect(full.exitCode, full.stderr).toBe(0); + expect(full.json).toMatchObject({ data: report }); + for (const [path, text] of originals) + expect(await readFile(path, "utf8")).toBe(text); + }, + ); +}); + /** The books environment and a probe of the application re-seeded under other ids, written as Evidence files. */ const writeCompareFiles = async (root: string) => { const fixture = environmentStateServeFixture(timedCaptureEvidence(BOOK_ROWS)); diff --git a/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts b/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts new file mode 100644 index 0000000000..64035ca4a3 --- /dev/null +++ b/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts @@ -0,0 +1,37 @@ +import { join } from "node:path"; +import { expect } from "vitest"; + +import { GHIDRA_PACKED_PROGRAM_RECOVERY } from "../../../src/config/ghidraExistingProject.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "explains how to import a packed Ghidra program before selecting a project", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-packed-project-doctor-"); + const result = await cli.run({ + arguments: ["--json", "doctor", "--provider", "ghidra"], + environment: { REA_GHIDRA_PROJECT_PATH: join(root, "program.gzf") }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + healthy: false, + scope_checks: expect.arrayContaining([ + expect.objectContaining({ + name: "runtime-configuration:REA_GHIDRA_PROJECT_PATH", + ok: false, + classification: "config_drift", + detail: `REA_GHIDRA_PROJECT_PATH: ${GHIDRA_PACKED_PROGRAM_RECOVERY}`, + }), + ]), + }); + for (const required of [ + "analyzeHeadless", + "-import", + "-noanalysis", + ".gpr", + "REA_GHIDRA_PROGRAM_NAME", + ]) + expect(GHIDRA_PACKED_PROGRAM_RECOVERY).toContain(required); + }, +); diff --git a/tests/boundary/cli/jsonOutput.test.ts b/tests/boundary/cli/jsonOutput.test.ts index 68c145ce17..1ccdf1c24c 100644 --- a/tests/boundary/cli/jsonOutput.test.ts +++ b/tests/boundary/cli/jsonOutput.test.ts @@ -61,6 +61,9 @@ describe("SDK output completion", () => { }); }, }); + // Terminal JSON stays with Incur; only pipes buffer for compact rewrite. + if (format === "json") + Object.defineProperty(destination, "isTTY", { value: true }); const output = createCliResultOutput( parseCliOutputArguments(["--format", format]), destination, @@ -75,6 +78,105 @@ describe("SDK output completion", () => { expect(destination.writableEnded).toBe(false); }, ); + + it("rewrites piped SDK JSON diagnostics compactly", async () => { + const value = { + code: "VALIDATION_ERROR", + message: "REA could not read the command arguments.", + fieldErrors: [{ path: "symbols", missing: true, expected: "array" }], + }; + const indented = `${JSON.stringify(value, null, 2)}\n`; + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: unknown, _encoding, callback) { + if (!Buffer.isBuffer(chunk)) { + callback(new TypeError("Expected a UTF-8 output buffer")); + return; + } + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + parseCliOutputArguments(["--json"]), + destination, + ); + if (output === undefined) throw new Error("Expected valid output controls"); + expect(output.captureSdkOutput(indented)).toBe(true); + expect(Buffer.concat(chunks).toString("utf8")).toBe(""); + await output.finishSdkOutput(1); + const text = Buffer.concat(chunks).toString("utf8"); + expect(text).toBe(`${JSON.stringify(value)}\n`); + expect(text).not.toContain("\n "); + expect(JSON.parse(text)).toEqual(value); + }); + + it("counts tokens for piped SDK JSON documents instead of rewriting them", async () => { + const value = { + version: "incur.v1", + commands: [{ name: "inspect-devicetree" }], + }; + const indented = `${JSON.stringify(value, null, 2)}\n`; + const compact = `${JSON.stringify(value)}\n`; + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: unknown, _encoding, callback) { + if (!Buffer.isBuffer(chunk)) { + callback(new TypeError("Expected a UTF-8 output buffer")); + return; + } + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + parseCliOutputArguments([ + "--llms-full", + "--format", + "json", + "--token-count", + ]), + destination, + ); + if (output === undefined) throw new Error("Expected valid output controls"); + expect( + output.sdkArguments(["--llms-full", "--format", "json", "--token-count"]), + ).toEqual(expect.any(Array)); + expect(output.captureSdkOutput(indented)).toBe(true); + await output.finishSdkOutput(0); + expect(Buffer.concat(chunks).toString("utf8")).toBe( + `${estimateTokenCount(compact)}\n`, + ); + }); + + it("preserves non-JSON piped --json output under --token-count (completion hooks)", async () => { + const hook = "# bash completion hook\ncomplete -F _rea rea\n"; + const chunks: Buffer[] = []; + const destination = new Writable({ + write(chunk: unknown, _encoding, callback) { + if (!Buffer.isBuffer(chunk)) { + callback(new TypeError("Expected a UTF-8 output buffer")); + return; + } + chunks.push(chunk); + callback(); + }, + }); + const output = createCliResultOutput( + parseCliOutputArguments([ + "completions", + "bash", + "--json", + "--token-count", + ]), + destination, + ); + if (output === undefined) throw new Error("Expected valid output controls"); + output.sdkArguments(["completions", "bash", "--json", "--token-count"]); + expect(output.captureSdkOutput(hook)).toBe(true); + await output.finishSdkOutput(0); + expect(Buffer.concat(chunks).toString("utf8")).toBe(hook); + }); }); describe("incremental CLI JSON", () => { diff --git a/tests/boundary/cli/markdownArgumentErrors.test.ts b/tests/boundary/cli/markdownArgumentErrors.test.ts new file mode 100644 index 0000000000..e7cc862641 --- /dev/null +++ b/tests/boundary/cli/markdownArgumentErrors.test.ts @@ -0,0 +1,290 @@ +import { Cli, Errors, z } from "incur"; +import { parseFragment, type DefaultTreeAdapterTypes } from "parse5"; +import { estimateTokenCount } from "tokenx"; +import { createMarkdownRenderer, disposeMdItInstance } from "vitepress"; +import { afterAll, expect, it } from "vitest"; + +import { + encodeCliArgumentErrors, + presentCliArgumentErrors, +} from "../../../src/cli/argumentErrors.js"; +import { + excludeDeclaredSensitiveArguments, + explicitSensitiveValueOptions, +} from "../../../src/cli/explicitSensitiveArguments.js"; + +import { cliTest } from "../../support/cli/cliFixture.js"; + +afterAll(() => disposeMdItInstance()); + +cliTest( + "renders complete validation choices in a Markdown table", + async ({ cli }) => { + const args = [ + "search", + "./missing-input", + "needle", + "--kind", + "unrecognized", + ]; + const markdown = await cli.run({ arguments: [...args, "--format", "md"] }); + const full = await cli.run({ + arguments: [...args, "--format", "md", "--full-output"], + }); + const count = await cli.run({ + arguments: [...args, "--format", "md", "--token-count"], + }); + const window = await cli.run({ + arguments: [ + ...args, + "--format", + "md", + "--full-output", + "--token-limit", + "20", + ], + }); + const json = await cli.run({ arguments: [...args, "--format", "json"] }); + expect(markdown.exitCode).toBe(1); + expect(full.exitCode).toBe(1); + expect(count.exitCode).toBe(1); + expect(window.exitCode).toBe(1); + expect(json.exitCode).toBe(1); + expect(markdown.stderr).toBe(""); + expect(json.stderr).toBe(""); + const failure = JSON.parse(json.stdout); + expect(failure.code).toBe("VALIDATION_ERROR"); + const message = failure.fieldErrors[0].message as string; + expect(message).toContain('"strings"|"procedures"'); + + const renderer = await createMarkdownRenderer(process.cwd(), { + linkify: false, + }); + const renderedCells = (output: string): string[] => { + const cells: string[] = []; + const visit = (node: DefaultTreeAdapterTypes.Node): void => { + if ("tagName" in node && node.tagName === "td") { + const text = (child: DefaultTreeAdapterTypes.Node): string => + child.nodeName === "#text" && "value" in child + ? child.value + : "childNodes" in child + ? child.childNodes.map(text).join("") + : ""; + cells.push(text(node)); + } else if ("childNodes" in node) node.childNodes.forEach(visit); + }; + visit(parseFragment(renderer.render(output))); + return cells; + }; + expect(renderedCells(markdown.stdout)).toContain(message); + expect(renderedCells(full.stdout)).toContain(message); + expect(Number(count.stdout.trim())).toBe( + estimateTokenCount(markdown.stdout.trimEnd()), + ); + expect(window.stdout).toContain("[truncated: showing tokens 0–20 of "); + expect(window.stdout).toContain("nextOffset"); + + // A different parser error has a much longer enum. Compare the visible + // cell with the unmodified machine-readable diagnostic, not a subset. + const doctorArgs = ["doctor", "--client", "x|y"]; + const doctorMd = await cli.run({ + arguments: [...doctorArgs, "--format", "md"], + }); + const doctorJson = await cli.run({ + arguments: [...doctorArgs, "--format", "json"], + }); + expect(doctorMd.exitCode).toBe(1); + expect(doctorJson.exitCode).toBe(1); + const doctorMessage = JSON.parse(doctorJson.stdout).fieldErrors[0] + .message as string; + expect(doctorMessage.split("|").length).toBeGreaterThan(2); + expect(renderedCells(doctorMd.stdout)).toContain(doctorMessage); + }, +); + +cliTest( + "redacts a pipe-containing declaration before Markdown encoding", + async ({ cli }) => { + const secret = '"har"|"mitmproxy"'; + const args = [ + "inspect-web-network-capture", + "/nonexistent/capture.har", + "bogus", + ]; + const run = (format: string, declared: boolean) => + cli.run({ + arguments: [ + ...args, + ...(declared ? ["--sensitive-value", secret] : []), + "--format", + format, + ], + }); + const markdown = await run("md", true); + const json = await run("json", true); + const control = await run("json", false); + expect(markdown.exitCode).toBe(1); + expect(json.exitCode).toBe(1); + expect(markdown.stderr).toBe(""); + expect(json.stderr).toBe(""); + expect(markdown.stdout).not.toContain('"har"\\|"mitmproxy"'); + const renderer = await createMarkdownRenderer(process.cwd(), { + linkify: false, + }); + const rendered = renderer.render(markdown.stdout); + expect(rendered).not.toContain(secret); + expect(rendered).toContain("[REDACTED]"); + const message = JSON.parse(json.stdout).fieldErrors[0].message as string; + expect(message).not.toContain(secret); + expect(message).toContain("[REDACTED]"); + expect(JSON.parse(control.stdout).fieldErrors[0].message).toContain(secret); + }, +); + +it("redacts Markdown punctuation in validation fields before presentation", async () => { + for (const secret of [ + "left|right", + "*markup*", + "left\\right", + "&value", + ]) { + const cli = Cli.create("sensitive-fixture", { sync: false }); + cli.command("inspect", { + options: z.object({ ...explicitSensitiveValueOptions }), + run: () => { + throw new Errors.ValidationError({ + message: "fixture", + fieldErrors: [ + { + code: "invalid_value", + missing: false, + path: "input", + expected: "", + received: secret, + message: `Value ${secret} is invalid`, + }, + ], + }); + }, + }); + presentCliArgumentErrors(cli); + const declared = excludeDeclaredSensitiveArguments(cli); + encodeCliArgumentErrors(cli); + const original = cli.serve.bind(cli); + const run = async (format: string, sensitive: boolean) => { + let stdout = ""; + let exitCode = 0; + await declared( + [ + "inspect", + ...(sensitive ? ["--sensitive-value", secret] : []), + "--format", + format, + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: (code) => { + exitCode = code; + }, + }, + original, + ); + expect(exitCode).toBe(1); + return stdout; + }; + const md = await run("md", true); + const json = JSON.parse(await run("json", true)); + const control = JSON.parse(await run("json", false)); + const renderer = await createMarkdownRenderer(process.cwd(), { + linkify: false, + }); + expect(md).not.toContain(secret); + expect(renderer.render(md)).not.toContain(secret); + expect(renderer.render(md)).toContain("[REDACTED]"); + expect(json.fieldErrors[0].received).toBe("[REDACTED]"); + expect(json.fieldErrors[0].message).toBe("Value [REDACTED] is invalid"); + expect(control.fieldErrors[0].received).toBe(secret); + expect(control.fieldErrors[0].message).toBe(`Value ${secret} is invalid`); + } +}); + +it("retains upstream Markdown literal-cell protections after redaction", async () => { + const value = "\t:smile: secret|token # {x}\t"; + const secret = "secret|token"; + const cli = Cli.create("literal-validation-fixture", { sync: false }); + cli.command("inspect", { + options: z.object({ ...explicitSensitiveValueOptions }), + run: () => { + throw new Errors.ValidationError({ + message: "fixture", + fieldErrors: [ + { + code: "invalid_value", + missing: false, + path: "input", + expected: "", + received: value, + message: `Value ${value} is invalid`, + }, + ], + }); + }, + }); + presentCliArgumentErrors(cli); + const declared = excludeDeclaredSensitiveArguments(cli); + encodeCliArgumentErrors(cli); + const original = cli.serve.bind(cli); + const run = async (format: string, sensitive: boolean) => { + let stdout = ""; + let exitCode = 0; + await declared( + [ + "inspect", + ...(sensitive ? ["--sensitive-value", secret] : []), + "--format", + format, + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: (code) => { + exitCode = code; + }, + }, + original, + ); + expect(exitCode).toBe(1); + return stdout; + }; + const md = await run("md", false); + const redactedMd = await run("md", true); + const json = JSON.parse(await run("json", false)); + const redactedJson = JSON.parse(await run("json", true)); + expect(md).toContain(" "); + expect(md).toContain(":smile:"); + expect(md).toContain("secret\\|token"); + expect(redactedMd).not.toContain("secret\\|token"); + const renderer = await createMarkdownRenderer(process.cwd(), { + linkify: false, + }); + const text = (node: DefaultTreeAdapterTypes.Node): string => + node.nodeName === "#text" && "value" in node + ? node.value + : "childNodes" in node + ? node.childNodes.map(text).join("") + : ""; + const rendered = text(parseFragment(renderer.render(md))); + const redactedRendered = text(parseFragment(renderer.render(redactedMd))); + expect(rendered).toContain(value); + expect(redactedRendered).not.toContain(secret); + expect(redactedRendered).toContain("[REDACTED]"); + expect(json.fieldErrors[0].received).toBe(value); + expect(redactedJson.fieldErrors[0].received).toBe( + value.replace(secret, "[REDACTED]"), + ); +}); diff --git a/tests/boundary/cli/markdownLiteralCells.test.ts b/tests/boundary/cli/markdownLiteralCells.test.ts index 36639b6875..9c3a8f2758 100644 --- a/tests/boundary/cli/markdownLiteralCells.test.ts +++ b/tests/boundary/cli/markdownLiteralCells.test.ts @@ -325,6 +325,8 @@ cliTest( list: [literal], bullet: "- literal", numbered: "1. literal", + numberedBare: "1.", + parenBare: "1)", rule: "---", indented: " literal\n- next\n# heading", nested: { value: 1 }, @@ -365,6 +367,8 @@ cliTest( expect(text(parseFragment(html))).toContain(literal); expect(html).toContain("**key**
# injected"); expect(html).toContain("literal
- next
# heading"); + expect(html).toContain("

1.

"); + expect(html).toContain("

1)

"); expect(html).not.toMatch(/<(?:strong|em|ul|ol|li|T|pre|hr)(?:\s|>)/u); expect(html).not.toContain('href="wrong"'); expect(html).not.toContain(" { + const root = await createTestTempDirectory("rea-mcp-add-value-"); + const home = join(root, "home"); + const settingsPath = (selectedHome: string) => + join(selectedHome, ".config", "amp", "settings.json"); + const run = (arguments_: readonly string[], selectedHome = home) => + cli.run({ + arguments: [...arguments_, "--json"], + cwd: root, + environment: { + HOME: selectedHome, + USERPROFILE: selectedHome, + XDG_CONFIG_HOME: join(selectedHome, ".config"), + }, + timeoutMs: 20_000, + }); + + for (const [flag, field] of [ + ["--agent", "agent"], + ["--command", "command"], + ["-c", "command"], + ["-c=", "command"], + ["--agent=", "agent"], + ] as const) { + const result = await run(["mcp", "add", flag]); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_value", + missing: false, + path: field, + }), + ]), + }); + await expect(access(settingsPath(home))).rejects.toMatchObject({ + code: "ENOENT", + }); + } + + // Incur would swallow the following flag as the option value; REA rejects it. + const swallowed = await run(["mcp", "add", "--command", "--agent", "amp"]); + expect(swallowed.exitCode, swallowed.stderr).toBe(1); + expect(swallowed.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_value", + missing: false, + path: "command", + }), + ]), + }); + await expect(access(settingsPath(home))).rejects.toMatchObject({ + code: "ENOENT", + }); + + const existingHome = join(root, "existing-home"); + const existingSettingsPath = settingsPath(existingHome); + await mkdir(join(existingHome, ".config", "amp"), { recursive: true }); + const existingSettings = '{"existing":true}\n'; + await writeFile(existingSettingsPath, existingSettings); + const rejectedWithExistingSettings = await run( + ["mcp", "add", "--agent"], + existingHome, + ); + expect( + rejectedWithExistingSettings.exitCode, + rejectedWithExistingSettings.stderr, + ).toBe(1); + expect(rejectedWithExistingSettings.json).toMatchObject({ + code: "VALIDATION_ERROR", + }); + expect(await readFile(existingSettingsPath, "utf8")).toBe(existingSettings); + + // A registration with an explicit agent still writes the Amp registration. + const registered = await run(["mcp", "add", "--agent", "amp"]); + expect(registered.exitCode, registered.stderr).toBe(0); + const settings = JSON.parse(await readFile(settingsPath(home), "utf8")); + expect(settings["amp.mcpServers"].rea).toMatchObject({ + command: expect.any(String), + }); +}); diff --git a/tests/boundary/cli/mcpDoctorHelpCli.test.ts b/tests/boundary/cli/mcpDoctorHelpCli.test.ts index 1d5f0cdeac..abf2e40c31 100644 --- a/tests/boundary/cli/mcpDoctorHelpCli.test.ts +++ b/tests/boundary/cli/mcpDoctorHelpCli.test.ts @@ -153,3 +153,31 @@ describe("MCP doctor help boundary", () => { CLI_INTEGRATION_TIMEOUT_MS, ); }); + +workspaceCliTest( + "accepts the same timing spellings as the ordinary CLI", + async ({ cli }) => { + for (const [flag, enabled] of [ + ["--no-timings", false], + ["--timings=false", false], + ["--timings=true", true], + ["--timings=other", false], + ["--timings=", false], + ] as const) { + const result = await cli.run({ + arguments: ["mcp", "doctor", "--json", flag], + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + healthy: true, + adapter: "production-stdio", + }); + if (enabled) + expect(JSON.parse(result.stderr)).toMatchObject({ + rea_timings: { version: 1, command_ms: expect.any(Number) }, + }); + else expect(result.stderr).toBe(""); + } + }, + CLI_INTEGRATION_TIMEOUT_MS, +); diff --git a/tests/boundary/cli/nativeDemangleCli.test.ts b/tests/boundary/cli/nativeDemangleCli.test.ts index 431cf02eff..c18b6f5422 100644 --- a/tests/boundary/cli/nativeDemangleCli.test.ts +++ b/tests/boundary/cli/nativeDemangleCli.test.ts @@ -69,4 +69,27 @@ describe("Swift demangling CLI with line-terminator characters", () => { ]), }); }); + + cliTest( + "emits compact JSON for missing-argument validation on piped stdout", + async ({ cli }) => { + const result = await cli.run({ + arguments: ["demangle-swift", "--json"], + environment, + timeoutMs: 20_000, + }); + expect(result.signal).toBeNull(); + expect(result.exitCode).toBe(1); + expect(result.stderr).toBe(""); + expect(result.json).toEqual( + expect.objectContaining({ + code: "VALIDATION_ERROR", + message: expect.stringMatching(/\S/), + }), + ); + // Piped stdout must stay compact; Incur's Formatter always indents. + expect(result.stdout).not.toContain("\n "); + expect(result.stdout).toBe(`${JSON.stringify(result.json)}\n`); + }, + ); }); diff --git a/tests/boundary/cli/nativeFeatureTraceDelivery.test.ts b/tests/boundary/cli/nativeFeatureTraceDelivery.test.ts new file mode 100644 index 0000000000..ae032e7520 --- /dev/null +++ b/tests/boundary/cli/nativeFeatureTraceDelivery.test.ts @@ -0,0 +1,226 @@ +import { readFile, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Cli } from "incur"; +import pino from "pino"; +import { expect, it } from "vitest"; +import { + createAnalysisExecution, + type AnalysisProvider, +} from "../../../src/application/AnalysisProvider.js"; +import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; +import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; +import { registerCoreBinaryCommands } from "../../../src/cli/coreBinaryCommands.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; +import { createEvidence } from "../../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { ok } from "../../../src/domain/result.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const identity = { id: "fixture", name: "Fixture", version: "1" } as const; +const effects = { + mutatesArtifact: false, + launchesProcess: false, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, +}; + +const verifyReplay = async ( + cli: ReturnType, + path: string, + snapshot: string, + root: string, + originalBundle: ReturnType, + traceId: string | undefined, +) => { + const output = join(root, "replay-evidence.json"); + let stdout = ""; + await cli.serve( + [ + "trace", + path, + "goal", + "--provider", + "fixture", + "--snapshot", + snapshot, + "--detail", + "summary", + "--evidence_output", + output, + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const response = JSON.parse(stdout); + expect(response, JSON.stringify(response)).toHaveProperty("summary"); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(output, "utf8")), + ); + expect(response.summary.normalized_result.parent_evidence_id).toBe(traceId); + expect(bundle.records.map((record) => record.evidence_id).sort()).toEqual( + originalBundle.records.map((record) => record.evidence_id).sort(), + ); +}; + +it.each([ + { detail: "summary", missing: false }, + { detail: "complete", missing: false }, + { detail: "summary", missing: true }, + { detail: "summary", missing: false, replay: true }, +])( + "publishes $detail trace only when its source closure is available (missing=$missing, replay=$replay)", + async ({ detail, missing, replay }) => { + const root = await createTestTempDirectory("rea-native-trace-cli-source-"); + const path = join(root, "fixture.hop"); + const output = join(root, "evidence.json"); + const snapshot = join(root, "snapshot.json"); + let allowProvider = true; + await writeFile(path, "synthetic analysis database"); + const history = createEvidence(undefined, identity, { + operation: "health", + parameters: { facet: "historical-project-analysis-reports" }, + result: { origin: "caller-project" }, + confidence: "observed", + ...(missing ? { evidenceLinks: ["ev_" + "a".repeat(64)] } : {}), + }); + const provider: AnalysisProvider = { + identity: () => identity, + capabilities: () => + ( + [ + "search_strings", + "search_procedures", + "xrefs", + "resolve_containing_procedure", + ] as const + ).map((operation) => ({ + provider: identity, + operation, + available: true, + reason: null, + cachePolicy: replay ? "snapshot" : "live", + limitations: [], + effects, + })), + resolveAnalysisProfile: async () => + ok({ profile: createAnalysisProfile(identity, { fixture: true }) }), + createClient: () => { + if (!allowProvider) + throw new Error("Snapshot replay reopened the provider"); + return { + execute: async (operation) => { + const value = + operation === "search_procedures" + ? [{ address: "0x1000", value: "goal" }] + : operation === "xrefs" + ? ["0x1010"] + : operation === "resolve_containing_procedure" + ? { + query_address: "0x1010", + found: false, + procedure: null, + reason: "not_in_procedure", + } + : operation === "search_strings" + ? [] + : null; + return ok( + createAnalysisExecution(value, identity, { + sourceEvidence: [history], + }), + ); + }, + close: async () => ok(null), + }; + }, + }; + const createSession = () => createTestBinarySession(provider); + const dependencies: DirectAnalysisDependencies = { + readConfiguration: () => parseConfig({}), + createBinarySession: createSession, + createNativeDispatchBinarySession: createSession, + createArtifactBinarySession: createSession, + createNativeBinarySession: createSession, + createManagedBinarySession: createSession, + }; + const cli = Cli.create("rea-native-trace-cli-source", { sync: false }); + registerCoreBinaryCommands( + cli, + pino({ enabled: false }), + (target, tool, args, options) => + runDirectAnalysis(dependencies, target, tool, args, options), + {}, + ); + let stdout = ""; + await cli.serve( + [ + "trace", + path, + "goal", + "--provider", + "fixture", + ...(replay ? ["--snapshot", snapshot] : []), + "--detail", + detail, + "--evidence_output", + output, + "--json", + ], + { + env: {}, + stdout: (text) => { + stdout += text; + }, + exit: () => undefined, + }, + ); + const response = JSON.parse(stdout); + if (missing) { + expect(response).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { reason: "missing", evidence_id: "ev_" + "a".repeat(64) }, + }); + await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" }); + } else { + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(output, "utf8")), + ); + const trace = bundle.records.find( + (record) => record.operation === "trace_feature", + ); + expect( + detail === "summary" + ? response.summary.normalized_result.parent_evidence_id + : response.evidence_id, + ).toBe(trace?.evidence_id); + expect(trace?.evidence_links).toContain(history.evidence_id); + expect( + bundle.records.some( + (record) => record.evidence_id === history.evidence_id, + ), + ).toBe(true); + if (replay) { + allowProvider = false; + await verifyReplay( + cli, + path, + snapshot, + root, + bundle, + trace?.evidence_id, + ); + } + } + }, +); diff --git a/tests/boundary/cli/nativeSignatureCli.test.ts b/tests/boundary/cli/nativeSignatureCli.test.ts index cae0929f86..f69de01daa 100644 --- a/tests/boundary/cli/nativeSignatureCli.test.ts +++ b/tests/boundary/cli/nativeSignatureCli.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { copyFile, mkdir } from "node:fs/promises"; +import { copyFile, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -91,3 +91,52 @@ describe.skipIf(process.platform !== "darwin")( ); }, ); + +describe.skipIf(process.platform !== "darwin" || process.arch !== "arm64")( + "native signature CLI for a universal arm64e binary", + () => { + cliTest( + "inspects the selected arm64e slice whatever its pointer-authentication ABI version", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-signature-arm64e-", + ); + const source = join(directory, "main.c"); + await writeFile(source, "int main(void) { return 0; }\n"); + // Current clang marks arm64e slices with ABI version 1 (arm64e.v1). + const program = join(directory, "universal"); + await run("/usr/bin/xcrun", [ + "clang", + "-arch", + "arm64e", + "-arch", + "x86_64", + source, + "-o", + program, + ]); + const { stderr } = await run("/usr/bin/codesign", [ + "-d", + "-a", + "arm64e", + "--verbose=4", + program, + ]); + const cdhash = /^CDHash=([0-9a-f]+)$/mu.exec(stderr)?.[1]; + expect(cdhash).toBeDefined(); + const result = await cli.run({ + arguments: ["inspect-signature", program, "--json"], + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { signed: true, cdhashes: [cdhash] }, + }); + }, + 60_000, + ); + }, +); diff --git a/tests/boundary/cli/objdumpIdentity.test.ts b/tests/boundary/cli/objdumpIdentity.test.ts new file mode 100644 index 0000000000..82a342c3de --- /dev/null +++ b/tests/boundary/cli/objdumpIdentity.test.ts @@ -0,0 +1,71 @@ +import { expect } from "vitest"; +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "does not attribute a caller-selected command to GNU objdump", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-with-objdump", + process.execPath, + "file_headers", + "--format", + "json", + ], + environment: { REA_OBJDUMP_COMMAND: process.execPath }, + }); + + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + provider: { + id: "objdump.command", + name: "objdump command", + version: null, + }, + parameters: { provider_executable: process.execPath }, + normalized_result: { + exit_code: expect.any(Number), + completion_status: "complete", + }, + }); + + const architectures = await cli.run({ + arguments: ["list-objdump-architectures", "--format", "json"], + environment: { REA_OBJDUMP_COMMAND: process.execPath }, + input: "", + }); + expect(architectures.exitCode).toBe(0); + expect(architectures.json).toMatchObject({ + provider: { + id: "objdump.command", + name: "objdump command", + version: null, + }, + subject: null, + }); + + const missing = join(tmpdir(), `missing-objdump-${randomUUID()}`); + const failed = await cli.run({ + arguments: [ + "inspect-with-objdump", + process.execPath, + "file_headers", + "--format", + "json", + ], + environment: { REA_OBJDUMP_COMMAND: missing }, + }); + expect(failed.exitCode).toBe(1); + expect(failed.json).toMatchObject({ + code: "capability_unavailable", + details: { + provider_id: "objdump.command", + diagnostics: { command: missing }, + }, + }); + }, +); diff --git a/tests/boundary/cli/reverseEngineeringCommands.test.ts b/tests/boundary/cli/reverseEngineeringCommands.test.ts index 77a7ceef67..a3482c8814 100644 --- a/tests/boundary/cli/reverseEngineeringCommands.test.ts +++ b/tests/boundary/cli/reverseEngineeringCommands.test.ts @@ -250,7 +250,7 @@ it("promotes only complete production cleanup-only Evidence after CLI cleanup re const result = await runObjdump(service, path); expect(result).toMatchObject({ - provider: { id: "gnu.objdump" }, + provider: { id: "objdump.command" }, normalized_result: { completion_status: "complete" }, }); expect(producedCleanupFailure?.cleanupOnly).toBe(true); @@ -552,7 +552,16 @@ it.each(["incomplete", "interrupt", "control"] as const)( rawResult: { output: "registers" }, }, ); - const sessions = new Map([["fixture-session", { id: "fixture-session" }]]); + const sessions = new Map([ + [ + "fixture-session", + { + id: "fixture-session", + launched: { process: { pid: 4242 } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, + }, + ], + ]); vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue( ok({ session_id: "fixture-session", backend: null }), ); @@ -642,6 +651,7 @@ it.each(["incomplete", "interrupt", "control"] as const)( details: { cleanup: "incomplete", resources: ["fixture-session"], + diagnostics: { retained_debugger_pids: [4242] }, partial_observation: { operation: "rizin_debug_command", parameters: { session_id: "fixture-session" }, diff --git a/tests/boundary/cli/traceNativeValuesEvidenceCli.test.ts b/tests/boundary/cli/traceNativeValuesEvidenceCli.test.ts new file mode 100644 index 0000000000..aab76306b6 --- /dev/null +++ b/tests/boundary/cli/traceNativeValuesEvidenceCli.test.ts @@ -0,0 +1,339 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Cli } from "incur"; +import pino from "pino"; +import { expect, it, vi } from "vitest"; + +import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js"; +import { registerCoreNativeCommands } from "../../../src/cli/coreNativeCommands.js"; +import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; +import type { CliInstance } from "../../../src/cli/types.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { + createEvidenceBundle, + parseEvidenceBundle, + type EvidenceBundle, +} from "../../../src/domain/evidenceBundle.js"; +import { REA_WORKFLOW_PROVIDER } from "../../../src/domain/investigationProviders.js"; +import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js"; +import { ok } from "../../../src/domain/result.js"; +import { composedWorkflowFixture } from "../../fixtures/composedWorkflowEvidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sourceProvider = { id: "fixture", name: "Fixture", version: "1" }; +const sourceTarget = { + path: "/fixtures/native.exe", + format: "pe" as const, + sha256: "a".repeat(64), +}; + +const createTraceFixture = () => { + const leaf = createEvidence(sourceTarget, sourceProvider, { + operation: "read_native_source", + parameters: { address: "0x1000" }, + result: { text: "return 0;" }, + }); + const procedure = createEvidence(sourceTarget, sourceProvider, { + operation: "analyze_function", + parameters: { procedure: "main" }, + result: { procedure: { address: "0x1000" } }, + evidenceLinks: [leaf.evidence_id], + }); + const traceResult = nativeValueTraceSchema.parse({ + seed: "main", + target_sha256: sourceTarget.sha256, + nodes: [ + { + id: "node-1", + procedure: "0x1000", + evidence_id: procedure.evidence_id, + kind: "operation", + operation: null, + parameter: null, + }, + ], + edges: [], + procedures: [ + { + address: "0x1000", + depth: 0, + evidence_id: procedure.evidence_id, + provider: "fixture", + analysis_profile_digest: null, + }, + ], + unknowns: [ + { procedure: "0x1000", address: null, reason: "fixture uncertainty" }, + ], + total_nodes: 2, + total_edges: 0, + decompilations: 1, + next_offset: 1, + page_truncated: true, + traversal: { status: "complete" }, + truncated: true, + limitations: ["fixture trace limitation"], + }); + const trace = createEvidence(sourceTarget, REA_WORKFLOW_PROVIDER, { + operation: "trace_native_values", + parameters: { procedure: "main" }, + result: traceResult, + confidence: "derived", + evidenceLinks: [procedure.evidence_id], + }); + const unrelated = createEvidence(sourceTarget, sourceProvider, { + operation: "address_name", + parameters: { address: "0x2000" }, + result: { name: "unrelated" }, + }); + const sessionBundle = createEvidenceBundle([ + trace, + procedure, + leaf, + unrelated, + ]); + return { leaf, procedure, trace, unrelated, sessionBundle }; +}; + +type DirectAnalysisRunner = Parameters[2]; + +const createTraceCli = (run: DirectAnalysisRunner) => { + const cli = Cli.create("rea-trace-evidence-test", { + version: "1", + sync: false, + }); + registerCoreNativeCommands( + cli as unknown as CliInstance, + pino({ enabled: false }), + run, + ); + return cli; +}; + +const runJsonCli = async ( + cli: ReturnType, + args: string[], +) => { + let stdout = ""; + await cli.serve(args, { + env: {}, + stdout: (text: string) => { + stdout += text; + }, + exit: () => undefined, + }); + return JSON.parse(stdout) as Record; +}; + +const verifyDirectAnalysisEvidenceBundleCallback = async ( + snapshotPath: string, +) => { + const fixture = await composedWorkflowFixture(); + let captured: EvidenceBundle | undefined; + const direct = parseEvidence( + await runDirectAnalysis( + fixture.dependencies, + fixture.path, + "binary_overview", + {}, + { snapshotPath, onEvidenceBundle: (bundle) => (captured = bundle) }, + ).then(projectCliAnalysisResult), + ); + expect(captured?.records.map(({ evidence_id }) => evidence_id)).toContain( + direct.evidence_id, + ); + + let replayedBundle: EvidenceBundle | undefined; + const replayed = parseEvidence( + await runDirectAnalysis( + fixture.dependencies, + fixture.path, + "binary_overview", + {}, + { + snapshotPath, + onEvidenceBundle: (bundle) => (replayedBundle = bundle), + }, + ).then(projectCliAnalysisResult), + ); + expect(replayed).toEqual(direct); + expect( + replayedBundle?.records.map(({ evidence_id }) => evidence_id), + ).toContain(replayed.evidence_id); +}; + +it("exports only the transitive source Evidence closure for a native value trace", async () => { + const { leaf, procedure, trace, unrelated, sessionBundle } = + createTraceFixture(); + const run = vi.fn( + async (_path, _tool, _arguments, options) => { + options?.onEvidenceBundle?.(sessionBundle); + return ok(trace); + }, + ); + const cli = createTraceCli(run); + const directory = await createTestTempDirectory("rea-trace-evidence-"); + const destination = join(directory, "trace-evidence.json"); + const delivered = await runJsonCli(cli, [ + "trace-native-values", + sourceTarget.path, + "main", + "--evidence_output", + destination, + "--json", + ]); + + expect(delivered.evidence_id).toBe(trace.evidence_id); + expect(run).toHaveBeenCalledOnce(); + const exported = parseEvidenceBundle( + JSON.parse(await readFile(destination, "utf8")), + ); + expect(exported.records.map(({ evidence_id }) => evidence_id).sort()).toEqual( + [trace.evidence_id, procedure.evidence_id, leaf.evidence_id].sort(), + ); + expect( + exported.records.some( + ({ evidence_id }) => evidence_id === unrelated.evidence_id, + ), + ).toBe(false); + + const summaryOutput = await runJsonCli(cli, [ + "trace-native-values", + "/missing-target", + "main", + "--detail", + "summary", + "--json", + ]); + expect(run).toHaveBeenCalledOnce(); + expect(JSON.stringify(summaryOutput)).toContain("evidence_output"); +}); + +it("publishes a bounded trace summary with a complete evidence bundle", async () => { + const { leaf, procedure, trace, sessionBundle } = createTraceFixture(); + const run = vi.fn( + async (_path, _tool, _arguments, options) => { + options?.onEvidenceBundle?.(sessionBundle); + return ok(trace); + }, + ); + const cli = createTraceCli(run); + const directory = await createTestTempDirectory("rea-trace-summary-"); + const destination = join(directory, "trace-summary-evidence.json"); + const receipt = await runJsonCli(cli, [ + "trace-native-values", + sourceTarget.path, + "main", + "--detail", + "summary", + "--evidence_output", + destination, + "--json", + ]); + + const summaryResult = ( + receipt.summary as { normalized_result?: unknown } | undefined + )?.normalized_result; + const expectedSummary = { + kind: "summary", + parent_operation: "trace_native_values", + parent_evidence_id: trace.evidence_id, + summary: { + seed: "main", + node_count: 1, + total_nodes: 2, + edge_count: 0, + procedure_count: 1, + unknown_count: 1, + decompilations: 1, + next_offset: 1, + page_truncated: true, + traversal: { status: "complete" }, + truncated: true, + limitation_count: 1, + }, + }; + expect(summaryResult).toMatchObject(expectedSummary); + const summaryEvidence = parseEvidence(receipt.summary); + expect(summaryEvidence.operation).toBe("inspect_analysis_view"); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(destination, "utf8")), + ); + expect(bundle.records.map(({ evidence_id }) => evidence_id).sort()).toEqual( + [trace.evidence_id, procedure.evidence_id, leaf.evidence_id].sort(), + ); + expect(run).toHaveBeenCalledOnce(); +}); + +it("retains completed trace Evidence when the session returns no bundle", async () => { + const { trace } = createTraceFixture(); + const run = vi.fn(async () => ok(trace)); + const cli = createTraceCli(run); + const directory = await createTestTempDirectory("rea-trace-no-bundle-"); + const destination = join(directory, "missing-bundle.json"); + const error = await runJsonCli(cli, [ + "trace-native-values", + sourceTarget.path, + "main", + "--evidence_output", + destination, + "--json", + ]); + + expect(error).toMatchObject({ + code: "unreadable_output", + details: { + operation: "trace_native_values", + partial_observation: { + operation: "trace_native_values", + normalized_result: { seed: "main" }, + }, + }, + }); + await expect(readFile(destination, "utf8")).rejects.toThrow(); + expect(run).toHaveBeenCalledOnce(); +}); + +it("reports a missing linked source with its identity and completed trace", async () => { + const { leaf, procedure, trace } = createTraceFixture(); + const run = vi.fn( + async (_path, _tool, _arguments, options) => { + options?.onEvidenceBundle?.(createEvidenceBundle([trace, procedure])); + return ok(trace); + }, + ); + const cli = createTraceCli(run); + const directory = await createTestTempDirectory("rea-trace-missing-source-"); + const destination = join(directory, "missing-source.json"); + const error = await runJsonCli(cli, [ + "trace-native-values", + sourceTarget.path, + "main", + "--evidence_output", + destination, + "--json", + ]); + + expect(error).toMatchObject({ + code: "evidence_integrity_mismatch", + details: { + evidence_id: leaf.evidence_id, + reason: "missing", + operation: "trace_native_values", + reference_scope: "retained-closure", + partial_observation: { + operation: "trace_native_values", + normalized_result: { seed: "main" }, + }, + }, + }); + await expect(readFile(destination, "utf8")).rejects.toThrow(); + expect(run).toHaveBeenCalledOnce(); +}); + +it("replays direct-analysis Evidence bundle callbacks without rerunning", async () => { + const directory = await createTestTempDirectory("rea-trace-callback-"); + await verifyDirectAnalysisEvidenceBundleCallback( + join(directory, "analysis-snapshot.json"), + ); +}); diff --git a/tests/boundary/evm/evmLimiterAvailability.test.ts b/tests/boundary/evm/evmLimiterAvailability.test.ts index d8ad789adf..7684146571 100644 --- a/tests/boundary/evm/evmLimiterAvailability.test.ts +++ b/tests/boundary/evm/evmLimiterAvailability.test.ts @@ -1,4 +1,7 @@ import { expect, it } from "vitest"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; @@ -41,33 +44,43 @@ it("probes the default limiter with executable and regular-file checks", async ( ]); }); -it("uses the exact absolute override and reports a failed executable check", async () => { - const limiter = "/fixture/util-linux/prlimit"; - const availability = await evmLimiterAvailability( - { REA_EVM_PRLIMIT_COMMAND: limiter }, - { - ...linux, - fs: { - access: async (path) => { - expect(path).toBe(limiter); - throw Object.assign(new Error("permission denied"), { - code: "EACCES", - }); - }, - stat: async () => { - throw new Error("stat must not run after access failure"); +it.each(["EACCES", "EPERM"])( + "uses the exact absolute override and reports denied access: %s", + async (code) => { + const limiter = "/fixture/util-linux/prlimit"; + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: limiter }, + { + ...linux, + fs: { + access: async (path) => { + expect(path).toBe(limiter); + throw Object.assign(new Error("permission denied"), { + code, + }); + }, + stat: async () => { + throw new Error("stat must not run after access failure"); + }, }, }, - }, - ); + ); - expect(availability).toMatchObject({ - status: "unavailable", - code: "executable_missing", - reason: expect.stringContaining(limiter), - diagnostics: { limiter_path: limiter }, - }); -}); + expect(availability).toMatchObject({ + status: "unavailable", + code: "runtime_unavailable", + reason: expect.stringContaining(limiter), + diagnostics: { limiter_path: limiter, system_code: code }, + }); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + expect(entry?.remediation).toContain("REA_EVM_PRLIMIT_COMMAND"); + expect(entry?.remediation).toContain("execute permission"); + }, +); it("rejects a relative override and non-file without probing an alternate command", async () => { const relative = await evmLimiterAvailability( @@ -84,7 +97,8 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(relative).toMatchObject({ status: "unavailable", - reason: "prlimit_not_absolute", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: "prlimit", code: "prlimit_not_absolute", @@ -104,11 +118,53 @@ it("rejects a relative override and non-file without probing an alternate comman ); expect(directory).toMatchObject({ status: "unavailable", - reason: "prlimit_not_file", + code: "not_configured", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: expect.objectContaining({ code: "prlimit_not_file" }), }); }); +it("delivers selected-path recovery from real filesystem probes to capability output", async () => { + const root = await createTestTempDirectory("rea-evm-limiter-recovery-"); + const executable = join(root, "caller-selected-limiter"); + await writeFile(executable, "not executed by the readiness probe", { + mode: 0o700, + }); + for (const [path, expectedCode, expectedReason] of [ + ["prlimit", "not_configured", "absolute executable regular file"], + [root, "not_configured", "absolute executable regular file"], + [ + join(root, "missing-limiter"), + "executable_missing", + "REA_EVM_PRLIMIT_COMMAND", + ], + [executable, null, null], + ] as const) { + const availability = await evmLimiterAvailability( + { REA_EVM_PRLIMIT_COMMAND: path }, + linux, + ); + expect(availability?.code).toBe(expectedCode); + expect(availability?.diagnostics.limiter_path).toBe(path); + if (availability === undefined) throw new Error("Expected limiter probe"); + const entry = buildCapabilityInventory(status, { + ...basePolicy, + evmLimiterAvailability: availability, + }).find(({ name }) => name === "inspect_evm_interface"); + if (expectedReason === null) { + expect(entry).toMatchObject({ available: true, remediation: null }); + } else { + expect(entry).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(entry?.remediation).toContain(expectedReason); + expect(entry?.remediation).toContain(path); + expect(entry?.remediation).toContain("util-linux prlimit"); + } + } +}); + it("rechecks on each observation so inventory follows the selected executable's current state", async () => { let accessible = true; const fs = { diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index 9edd10309e..1e98c38490 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -970,7 +970,7 @@ it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( }); if (scenario === "directory") expect(result.error).toMatchObject({ - reason: "prlimit_not_file", + reason: expect.stringContaining("absolute executable regular file"), diagnostics: { limiter_path: limiter, code: "prlimit_not_file", diff --git a/tests/boundary/filesystem/artifactSelectedName.test.ts b/tests/boundary/filesystem/artifactSelectedName.test.ts index bcf997b118..60bf87fab4 100644 --- a/tests/boundary/filesystem/artifactSelectedName.test.ts +++ b/tests/boundary/filesystem/artifactSelectedName.test.ts @@ -1,5 +1,5 @@ import { projectCliAnalysisResult } from "../../../src/cli/projectCliAnalysisResult.js"; -import { readFile, symlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { expect, it, vi } from "vitest"; @@ -129,3 +129,58 @@ it.skipIf(process.platform === "win32")( expect(admitted.value.kind).toBe("executable"); }, ); + +it.skipIf(process.platform === "win32")( + "classifies a bundle's executable by its own name and keeps the bundle facts", + async () => { + const root = await createTestTempDirectory("rea-artifact-bundle-name-"); + const bundle = join(root, "Fixture.app"); + await mkdir(bundle); + const infoPlist = join(bundle, "Info.plist"); + await writeFile( + infoPlist, + "CFBundleExecutablemain.js", + ); + const executable = join(bundle, "main.js"); + await writeFile(executable, "export const value = 1;\n"); + const admitted = await parseBinaryTarget(bundle); + if (!admitted.ok) throw admitted.error; + expect(admitted.value).toMatchObject({ + path: executable, + kind: "artifact", + format: "javascript", + bundleInfoPlist: infoPlist, + artifactSelectedPath: bundle, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps the bundle facts when the executable is a script stub with a trailing archive", + async () => { + const root = await createTestTempDirectory("rea-artifact-bundle-zip-"); + const bundle = join(root, "Fixture.app"); + await mkdir(bundle); + const infoPlist = join(bundle, "Info.plist"); + await writeFile( + infoPlist, + "CFBundleExecutablemain", + ); + const writer = new ZipWriter(new Uint8ArrayWriter()); + await writer.add("payload.txt", new TextReader("appended archive")); + const executable = join(bundle, "main"); + await writeFile( + executable, + Buffer.concat([Buffer.from("#!/bin/sh\nexit 0\n"), await writer.close()]), + ); + const admitted = await parseBinaryTarget(bundle); + if (!admitted.ok) throw admitted.error; + expect(admitted.value).toMatchObject({ + path: executable, + kind: "archive", + format: "zip", + bundleInfoPlist: infoPlist, + artifactSelectedPath: bundle, + }); + }, +); diff --git a/tests/boundary/filesystem/asarListingAdmission.test.ts b/tests/boundary/filesystem/asarListingAdmission.test.ts index f2b0ee4c0c..2705cac904 100644 --- a/tests/boundary/filesystem/asarListingAdmission.test.ts +++ b/tests/boundary/filesystem/asarListingAdmission.test.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { buffer } from "node:stream/consumers"; import { setImmediate } from "node:timers/promises"; import { expect, it } from "vitest"; @@ -88,6 +88,82 @@ it.each(["..", ".", "", "a/../b", "a/b", "a\0b"])( }, ); +/** Listing failures name the member, then the archive that holds it. The + * reader may report the canonical spelling of a temporary directory, which can + * contain spaces, so only the archive name is matched exactly. */ +const inArchive = (message: string, path: string) => + expect.stringMatching( + new RegExp( + `^${message.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")} in .*[\\\\/]${basename(path).replace(".", "\\.")}$`, + "u", + ), + ); + +it.each([ + [ + { size: 0, offset: 0 }, + 'ASAR entry "app/main.js" metadata is malformed (offset)', + ], + [ + { size: 0, offset: "0", unpacked: "yes", executable: 1 }, + 'ASAR entry "app/main.js" metadata is malformed (unpacked, executable)', + ], + ["entry", 'ASAR entry "app/main.js" metadata is malformed (entry)'], + [{}, 'ASAR entry "app/main.js" has no directory, link or file metadata'], +])( + "names the member whose metadata %j stops inventory", + async (metadata, message) => { + const json = JSON.stringify({ + files: { app: { files: { "main.js": metadata } } }, + }); + const path = await archiveWith(asarHeaderBytes(json)); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "format", + message: inArchive(message, path), + }); + }, +); + +it("names the rejected root fields", async () => { + const path = await archiveWith( + asarHeaderBytes(JSON.stringify({ files: {}, unpacked: "yes" })), + ); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "format", + message: inArchive("ASAR root metadata is malformed (unpacked)", path), + }); +}); + +it("bounds the quoted unsafe ASAR path component", async () => { + const component = `${"a".repeat(300)}/x`; + const json = JSON.stringify({ + files: { [component]: { size: 0, offset: "0" } }, + }); + const path = await archiveWith(asarHeaderBytes(json)); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "path", + message: inArchive( + `ASAR header has an unsafe path component under the archive root: ${JSON.stringify("a".repeat(256))}... (302 UTF-16 code units)`, + path, + ), + }); +}); + +it("bounds the quoted parent of an unsafe ASAR path component", async () => { + const parent = "d".repeat(300); + const json = JSON.stringify({ + files: { [parent]: { files: { "..": { size: 0, offset: "0" } } } }, + }); + const path = await archiveWith(asarHeaderBytes(json)); + await expect(inventoryArtifact(path)).rejects.toMatchObject({ + reason: "path", + message: inArchive( + `ASAR header has an unsafe path component under ${JSON.stringify("d".repeat(256))}... (300 UTF-16 code units): ".."`, + path, + ), + }); +}); + it("orders raw names portably and retains prototype-named entries and links", async () => { const path = await archiveWith( asarHeaderBytes( @@ -183,6 +259,137 @@ it.each([undefined, "01", "-1", "9007199254740992"])( }, ); +const invalidSize = (declared: string) => + `ASAR entry declares ${declared}, which is not a non-negative safe integer.`; +const unretainedSize = + "ASAR entry declares a numeric size outside the safe-integer range; its exact source value was not retained."; + +// Header spellings, so values JSON.parse rounds or overflows stay exact here. +it.each([ + ["-1000", invalidSize("size -1000")], + ["1.5", invalidSize("size 1.5")], + ['"12"', invalidSize('size "12"')], + ["null", invalidSize("a null size")], + ["true", invalidSize("a boolean size")], + ["{}", invalidSize("an object size")], + ["[]", invalidSize("an array size")], + [ + JSON.stringify("9".repeat(300)), + invalidSize( + `size ${JSON.stringify("9".repeat(256))}... (300 UTF-16 code units)`, + ), + ], + ["9007199254740992", unretainedSize], + ["9007199254740993", unretainedSize], + ["1e400", unretainedSize], + ["-1e400", unretainedSize], +])( + "inventories the other members when one declares size %s", + async (sizeText, limitation) => { + // Figma Desktop ships `.codesign` with size -1000 and the empty-input digest. + const empty = createHash("sha256").update("").digest("hex"); + const payload = Buffer.from("console.log(1);\n"); + const digest = createHash("sha256").update(payload).digest("hex"); + const bytes = Buffer.concat([ + asarHeaderBytes( + JSON.stringify({ + files: { + ".codesign": { + size: "__SIZE__", + offset: "0", + integrity: { algorithm: "SHA256", hash: empty }, + }, + "main.js": { + size: payload.length, + offset: "0", + integrity: { algorithm: "SHA256", hash: digest }, + }, + }, + }).replace('"__SIZE__"', sizeText), + ), + payload, + ]); + const path = await archiveWith(bytes); + + const reader = new AsarArtifactReader(path); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + const invalid = entries.find((entry) => entry.path === ".codesign"); + expect(invalid).toMatchObject({ + kind: "file", + declaredSize: null, + declaredIntegrity: { algorithm: "sha256", digest: empty }, + limitations: [limitation], + }); + await expect(reader.open(invalid!)).rejects.toMatchObject({ + reason: "unavailable", + message: "ASAR entry declares no readable size: .codesign", + }); + const valid = entries.find((entry) => entry.path === "main.js")!; + expect( + Buffer.compare(await buffer(await reader.open(valid)), payload), + ).toBe(0); + } finally { + await reader.close(); + } + + const inventory = await inventoryArtifact(path); + const occurrence = (name: string) => + inventory.occurrences.find((row) => row.logical_path === name); + expect(occurrence(".codesign")).toMatchObject({ + artifact_id: null, + declared_size: null, + hash_status: "unavailable", + limitations: [ + limitation, + "ASAR packed entry bytes were not read because its declared size cannot bound them; no content hash or child artifact was produced.", + ], + }); + expect(occurrence("main.js")?.hash_status).toBe("verified"); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + }, +); + +it("reads unpacked members and lists directories whose declared size is invalid", async () => { + const payload = Buffer.from("native"); + const digest = createHash("sha256").update(payload).digest("hex"); + const bytes = asarHeaderBytes( + JSON.stringify({ + files: { + companion: { + size: -1, + unpacked: true, + integrity: { algorithm: "SHA256", hash: digest }, + }, + folder: { size: -1, files: { child: { size: 0, offset: "0" } } }, + }, + }), + ); + const path = await archiveWith(bytes); + await mkdir(`${path}.unpacked`); + await writeFile(join(`${path}.unpacked`, "companion"), payload); + const limitation = + "ASAR entry declares size -1, which is not a non-negative safe integer."; + const inventory = await inventoryArtifact(path); + const occurrence = (name: string) => + inventory.occurrences.find((row) => row.logical_path === name); + // Unpacked bytes come from the companion file, not a packed range. + expect(occurrence("companion")).toMatchObject({ + declared_size: null, + unpacked: true, + hash_status: "verified", + limitations: [limitation], + }); + expect(occurrence("companion")?.artifact_id).not.toBeNull(); + expect(occurrence("folder")).toMatchObject({ + entry_kind: "directory", + declared_size: null, + limitations: [limitation], + }); + expect(occurrence("folder/child")?.hash_status).toBe("verified"); +}); + it("preserves directory and link priority with unrecognized integrity declarations", async () => { const bytes = asarHeaderBytes( JSON.stringify({ diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 1452dfb509..b25d603cd3 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -446,6 +446,34 @@ describe("binary target I/O: app plist permissions and decoding", () => { }); }); +describe("binary target I/O: OpenStep app plist escapes", () => { + it.each([ + '{ CFBundleExecutable = "M\\U0061in"; }', + '{ CFBundleExecutable = "M\\141in"; }', + '{ "CFBundle\\U0045xecutable" = "Main"; }', + ])( + "resolves an OpenStep bundle executable with escaped keys or values in %s", + async (plistContent) => { + const directory = await createTestTempDirectory( + "rea-app-openstep-escapes-", + ); + const app = join(directory, "Probe.app"); + const programs = join(app, "Contents", "MacOS"); + await mkdir(programs, { recursive: true }); + await writeFile(join(app, "Contents", "Info.plist"), plistContent); + await writeFile(join(programs, "Main"), "binary"); + const result = await resolveAppBundleExecutable(app); + expect(result).toEqual({ + ok: true, + value: { + executable: join(programs, "Main"), + infoPlist: join(app, "Contents", "Info.plist"), + }, + }); + }, + ); +}); + describe("binary target I/O: package classification", () => { it("classifies ZIP package families and text artifacts without Hopper", async () => { const directory = await createTestTempDirectory("rea-artifact-target-"); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 03b936b5d0..e5710daa25 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -346,6 +346,7 @@ it.each([ "tests/support/reverseEngineeringService.ts", "tests/support/ownedRizinCommandEvidenceTests.ts", "tests/support/reverseDebuggerMcpTestHelpers.ts", + "tests/boundary/cli/debuggerOneShotExit.test.ts", "tests/boundary/cli/gdbStartupCleanupCli.test.ts", "vitest.reverse-tools.config.ts", "vitest.reverse-tools-semantics.config.ts", @@ -878,7 +879,14 @@ it.each([ ["install.sh", { code: true, runtime: true, package: true, evm: false }], [ ".agents/skills/reverse-engineer-anything/SKILL.md", - { code: true, runtime: true, docs: true, package: false, evm: false }, + { + code: true, + runtime: true, + docs: true, + website: true, + package: false, + evm: false, + }, ], [ "scripts/verify-real-ghidra.mjs", @@ -896,6 +904,18 @@ it.each([ "tests/boundary/cli/nativePlistCli.test.ts", { code: true, apple: true, package: false, runtime: false }, ], + [ + "tests/boundary/process/nativeUiVerifierLane.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts", + { code: true, apple: true, package: false, runtime: false }, + ], + [ + "tests/conformance/native/ui-offscreen.swift", + { code: true, apple: true, package: false, runtime: false }, + ], [ "src/artifacts/InesArtifactReader.ts", { code: true, apple: true, package: false, runtime: true }, diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 7bb9cc22d5..474bf5be61 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -62,6 +62,7 @@ describe("client configuration filesystem lifecycle", () => { "vscode", "trae", "workbuddy", + "lm_studio", "grok_build", "omp", "pi", diff --git a/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts new file mode 100644 index 0000000000..25c3abf32c --- /dev/null +++ b/tests/boundary/filesystem/clientRegistrationProviderSettings.test.ts @@ -0,0 +1,290 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { recordedProviderSettings } from "../../../src/application/ClientRegistrationEnvironment.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +import { CURRENT_ENTRYPOINT_PATH } from "../../../src/process/CurrentEntrypointPath.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +beforeEach(clearClientLocationEnvironment); +afterEach(() => { + vi.unstubAllEnvs(); +}); + +const selected = { + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/opt/jdk", +}; +const entry = (env: unknown) => + JSON.stringify({ + mcpServers: { + rea: { + command: process.execPath, + args: [CURRENT_ENTRYPOINT_PATH, "mcp"], + ...(env === undefined ? {} : { env }), + }, + }, + }); +const readClaudeCode = async (env: unknown) => { + const home = await createTestTempDirectory("rea-recorded-settings-"); + await writeFile(join(home, ".claude.json"), entry(env)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + return status; +}; + +it.each([ + [ + "a registration with no environment map", + undefined, + { + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }, + ], + [ + "a registration recording only part of the selection", + { GHIDRA_INSTALL_DIR: "/opt/ghidra" }, + { unrecorded: ["JAVA_HOME"], differing: [], unresolved: [] }, + ], + [ + "a registration recording another value", + { GHIDRA_INSTALL_DIR: "/opt/other", JAVA_HOME: "/opt/jdk" }, + { unrecorded: [], differing: ["GHIDRA_INSTALL_DIR"], unresolved: [] }, + ], +])("reports names only for %s", async (_label, env, provider_settings) => { + expect(await readClaudeCode(env)).toMatchObject({ + client: "claude_code", + provider_settings, + }); +}); + +it.each([ + ["a non-string value", { GHIDRA_INSTALL_DIR: 1 }], + ["a peer setting the runtime parser rejects", { REA_LOG_LEVEL: "shout" }], +])( + "leaves a malformed map with %s to the invalid registration result", + async (_label, env) => { + const status = await readClaudeCode(env); + expect(status?.state).toBe("invalid"); + expect(status).not.toHaveProperty("provider_settings"); + }, +); + +it("omits the comparison when the registration records the selection", async () => { + expect(await readClaudeCode(selected)).not.toHaveProperty( + "provider_settings", + ); +}); + +it("keeps recorded values out of the status", async () => { + const status = await readClaudeCode({ + GHIDRA_INSTALL_DIR: "/recorded/secret-path", + }); + expect(status?.provider_settings?.differing).toEqual(["GHIDRA_INSTALL_DIR"]); + expect(JSON.stringify(status)).not.toContain("/recorded/secret-path"); +}); + +it("does not read the comparison for callers that did not ask", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-off-"); + await writeFile(join(home, ".claude.json"), entry(undefined)); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {} }, + ); + expect(status).not.toHaveProperty("provider_settings"); +}); + +it("retains the comparison on a stale launcher, which setup can still repair", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-stale-"); + await writeFile( + join(home, ".claude.json"), + JSON.stringify({ + mcpServers: { + rea: { + command: "npx", + args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses(home, "/current/rea", { + environment: {}, + providerSettings: selected, + }); + expect(status).toMatchObject({ + state: "stale", + provider_settings: { unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"] }, + }); +}); + +it("reads Codex's environment table", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-codex-"); + await mkdir(join(home, ".codex")); + await writeFile( + join(home, ".codex/config.toml"), + `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, "mcp"]\nstartup_timeout_sec = 30\ntool_timeout_sec = 3600\n[mcp_servers.rea.env]\nGHIDRA_INSTALL_DIR = "/opt/ghidra"\n`, + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "codex", + provider_settings: { + unrecorded: ["JAVA_HOME"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads OpenCode's differently named environment member", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-open-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + await writeFile( + join(home, ".config/opencode/opencode.json"), + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment: { JAVA_HOME: "/opt/jdk" }, + }, + }, + }), + ); + const [status] = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, providerSettings: selected }, + ); + expect(status).toMatchObject({ + client: "opencode", + provider_settings: { + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }, + }); +}); + +it("reads the effective view of layered OpenCode files", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-layer-"); + await mkdir(join(home, ".config/opencode"), { recursive: true }); + const layer = (environment: Readonly>) => + JSON.stringify({ + mcp: { + rea: { + type: "local", + command: [process.execPath, CURRENT_ENTRYPOINT_PATH, "mcp"], + environment, + }, + }, + }); + const override = join(home, "override.json"); + await writeFile( + join(home, ".config/opencode/opencode.json"), + layer({ JAVA_HOME: "/opt/jdk" }), + ); + await writeFile(override, layer({ GHIDRA_INSTALL_DIR: "/opt/ghidra" })); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { + environment: { OPENCODE_CONFIG: override }, + providerSettings: selected, + }, + ); + expect( + statuses.find(({ client }) => client === "opencode"), + ).not.toHaveProperty("provider_settings"); +}); + +it("keeps a Cordis expression unresolved instead of absent or different", async () => { + const home = await createTestTempDirectory("rea-recorded-settings-dsh-"); + await mkdir(join(home, ".dsh")); + await writeFile( + join(home, ".dsh/cordis.patch.yml"), + `- insert: + - id: rea-mcp + name: "@deepseek-ai/dsh-mcp-client" + config: + serverName: rea + transport: stdio + command: ${JSON.stringify(process.execPath)} + args: [${JSON.stringify(CURRENT_ENTRYPOINT_PATH)}, mcp] + env: + GHIDRA_INSTALL_DIR: !!js process.env.GHIDRA_INSTALL_DIR + JAVA_HOME: /opt/other +`, + ); + const statuses = await readClientRegistrationStatuses( + home, + CURRENT_ENTRYPOINT_PATH, + { environment: {}, platform: "linux", providerSettings: selected }, + ); + expect(statuses.find((status) => status.client === "dsh")).toMatchObject({ + provider_settings: { + unrecorded: [], + differing: ["JAVA_HOME"], + unresolved: ["GHIDRA_INSTALL_DIR"], + }, + }); +}); + +it("folds recorded environment names on Windows", () => { + const servers = { + rea: { env: { java_home: "/opt/jdk" } }, + }; + const parsed = { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + } as const; + expect(recordedProviderSettings(parsed, selected, "win32")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, selected, "linux")).toEqual({ + unrecorded: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + differing: [], + unresolved: [], + }); + expect(recordedProviderSettings(parsed, {}, "linux")).toBeUndefined(); +}); + +it("does not treat a map it could not read as absent", () => { + const servers = { rea: { env: { GHIDRA_INSTALL_DIR: 1 } } }; + expect( + recordedProviderSettings( + { + document: { mcpServers: servers }, + servers, + serversPath: ["mcpServers"], + dialect: "json", + environmentKey: "env", + legacyServers: {}, + }, + selected, + "linux", + ), + ).toEqual({ + unrecorded: [], + differing: [], + unresolved: ["GHIDRA_INSTALL_DIR", "JAVA_HOME"], + }); +}); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index 269f0ef68a..324689d0a6 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -167,6 +167,8 @@ describe("client registration environment validation", () => { const home = await createTestTempDirectory("rea-registration-budget-"); await mkdir(join(home, ".cursor")); await mkdir(join(home, ".codex")); + await mkdir(join(home, ".lmstudio")); + await mkdir(join(home, ".kimi-code")); const entry = { command: "npx", args: ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"], @@ -175,6 +177,11 @@ describe("client registration environment validation", () => { join(home, ".cursor/mcp.json"), JSON.stringify({ mcpServers: { rea: entry } }), ); + for (const path of [".lmstudio/mcp.json", ".kimi-code/mcp.json"]) + await writeFile( + join(home, path), + JSON.stringify({ mcpServers: { rea: entry } }), + ); await writeFile( join(home, ".codex/config.toml"), `[mcp_servers.rea]\ncommand = "npx"\nargs = ["-y", "${PRODUCT_IDENTITY.registrationPackageSpecifier}", "mcp"]\n`, @@ -194,10 +201,13 @@ describe("client registration environment validation", () => { // The failing resolver must not invalidate non-Kimi/Codex registrations. expect(byClient.has("cursor")).toBe(true); expect(byClient.get("cursor")?.state).not.toBe("invalid"); - expect(byClient.get("codex")).toMatchObject({ - state: "invalid", - remediation: expect.stringContaining("budget unavailable"), - }); + expect(byClient.has("lm_studio")).toBe(true); + expect(byClient.get("lm_studio")?.state).not.toBe("invalid"); + for (const client of ["codex", "kimi"]) + expect(byClient.get(client)).toMatchObject({ + state: "invalid", + remediation: expect.stringContaining("budget unavailable"), + }); }); }); diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index c144d41503..054157520c 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -500,6 +500,200 @@ it.each(["Game_Data/Managed", "Contents/Resources/Data/Managed"])( }, ); +it("orders member probes for Unity predefined script assemblies before every managed artifact probe", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-members-"); + const unity = buildManagedPeFixture({ + references: ["UnityEngine.CoreModule"], + }); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, "Game_Data/Managed/Assembly-CSharp.dll", unity); + await put(root, "Game_Data/Managed/ASSEMBLY-CSHARP-FIRSTPASS.DLL", plain); + await put(root, "Game_Data/Managed/Game.Logic.dll", unity); + await put(root, "Game_Data/Managed/UnityEngine.CoreModule.dll", plain); + await put(root, "Game_Data/Managed/Nested/Assembly-CSharp.dll", plain); + await put(root, "Other_Data/Managed/Assembly-CSharp.dll", plain); + await put(root, "Plugins/Assembly-CSharp.dll", unity); + const { report } = await inspect(root); + const relative = new Map( + report.files.map((file) => [file.file_id, file.path]), + ); + const rows = report.next_probes.map((probe) => [ + probe.operation, + relative.get(probe.file_id), + ]); + expect( + rows.filter(([operation]) => operation === "inspect_managed_members"), + ).toEqual([ + [ + "inspect_managed_members", + "Game_Data/Managed/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + ], + ["inspect_managed_members", "Game_Data/Managed/Assembly-CSharp.dll"], + ]); + expect(rows.slice(0, 2).map(([operation]) => operation)).toEqual([ + "inspect_managed_members", + "inspect_managed_members", + ]); + expect( + rows.filter(([operation]) => operation === "inspect_managed_artifact"), + ).toHaveLength(7); + for (const probe of report.next_probes) + if (probe.operation === "inspect_managed_members") + expect(probe.rationale).toContain( + 'Start with detail "summary" and page rows through inspect_analysis_view.', + ); +}); + +it("offers member probes for predefined script assemblies in a macOS bundle's managed directory", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-bundle-"); + const managed = "Contents/Resources/Data/Managed"; + await put( + root, + `${managed}/Assembly-CSharp.dll`, + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + await put( + root, + `${managed}/Game.Logic.dll`, + buildManagedPeFixture({ references: ["System.Runtime"] }), + ); + const { report } = await inspect(root); + const relative = new Map( + report.files.map((file) => [file.file_id, file.path]), + ); + expect( + report.next_probes + .filter((probe) => probe.operation === "inspect_managed_members") + .map((probe) => relative.get(probe.file_id)), + ).toEqual([`${managed}/Assembly-CSharp.dll`]); + expect(report.next_probes[0]?.operation).toBe("inspect_managed_members"); +}); + +it.each(["Game_Data", "Game_Data/Managed"])( + "offers no member probe when the root is %s itself", + async (rootDirectory) => { + const root = await createTestTempDirectory("rea-desktop-mono-data-root-"); + await put( + root, + "Game_Data/Managed/Assembly-CSharp.dll", + buildManagedPeFixture({ references: ["UnityEngine.CoreModule"] }), + ); + const { report } = await inspect(join(root, rootDirectory)); + const operations = report.next_probes.map((probe) => probe.operation); + expect(operations).toContain("inspect_managed_artifact"); + expect(operations).not.toContain("inspect_managed_members"); + }, +); + +it("offers no member probes when no managed directory has a Unity reference witness", async () => { + const root = await createTestTempDirectory("rea-desktop-mono-no-witness-"); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, "Game_Data/Managed/Assembly-CSharp.dll", plain); + await put(root, "Game_Data/Managed/Assembly-CSharp-firstpass.dll", plain); + await put(root, "Contents/Resources/Data/Managed/Assembly-CSharp.dll", plain); + const { report } = await inspect(root); + expect(report.candidates.map((row) => row.family)).toEqual(["dotnet"]); + expect(report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_artifact", + "inspect_managed_artifact", + "inspect_managed_artifact", + ]); +}); + +const BACKUP = "Game_BackUpThisFolder_ButDontShipItWithYourGame"; + +it("offers member probes for predefined script assemblies in a game directory's IL2CPP backup folder", async () => { + const root = await createTestTempDirectory("rea-desktop-il2cpp-backup-"); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(root, `${BACKUP}/Managed/Assembly-CSharp.dll`, plain); + await put( + root, + "OTHER_BACKUPTHISFOLDER_BUTDONTSHIPITWITHYOURGAME/MANAGED/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + plain, + ); + await put(root, `${BACKUP}/Managed/Game.Logic.dll`, plain); + await put(root, `${BACKUP}/Managed/Nested/Assembly-CSharp.dll`, plain); + await put(root, `${BACKUP}/Assembly-CSharp.dll`, plain); + await put( + root, + "_BackUpThisFolder_ButDontShipItWithYourGame/Managed/Assembly-CSharp.dll", + plain, + ); + await put(root, "Game_BackUpThisFolder/Managed/Assembly-CSharp.dll", plain); + const metadata = Buffer.alloc(8); + metadata.writeUInt32LE(0xfab11baf); + metadata.writeUInt32LE(31, 4); + await put( + root, + "Game_Data/il2cpp_data/Metadata/global-metadata.dat", + metadata, + ); + const { report } = await inspect(root); + const relative = new Map(report.files.map((file) => [file.file_id, file])); + const members = report.next_probes.filter( + (probe) => probe.operation === "inspect_managed_members", + ); + expect( + members.map((probe) => relative.get(probe.file_id)?.path).sort(), + ).toEqual( + [ + `${BACKUP}/Managed/Assembly-CSharp.dll`, + "OTHER_BACKUPTHISFOLDER_BUTDONTSHIPITWITHYOURGAME/MANAGED/ASSEMBLY-CSHARP-FIRSTPASS.DLL", + ].sort(), + ); + for (const probe of members) { + expect(probe.rationale).toContain( + "correspondence to the shipped native code is unknown", + ); + expect(relative.get(probe.file_id)?.facts).toContainEqual({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { + backup_directory: relative.get(probe.file_id)!.path.split("/")[0], + }, + }); + } + const order = report.next_probes.map((probe) => probe.operation); + expect(order.lastIndexOf("inspect_managed_members")).toBeLessThan( + order.indexOf("inspect_il2cpp_metadata"), + ); + expect(report.candidates.map((row) => row.family)).not.toContain( + "unity-mono", + ); + expect( + report.files.filter((file) => + file.facts.some((fact) => fact.kind === "unity-il2cpp-backup-convention"), + ), + ).toHaveLength(2); +}); + +it("recognizes an admitted root that is itself the IL2CPP backup folder", async () => { + const parent = await createTestTempDirectory( + "rea-desktop-il2cpp-backup-root-", + ); + const plain = buildManagedPeFixture({ references: ["System.Runtime"] }); + await put(parent, `${BACKUP}/Managed/Assembly-CSharp.dll`, plain); + await put(parent, "Ordinary/Managed/Assembly-CSharp.dll", plain); + const backup = await inspect(join(parent, BACKUP)); + expect(backup.report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_members", + "inspect_managed_artifact", + ]); + expect( + backup.report.files.find( + (file) => file.path === "Managed/Assembly-CSharp.dll", + )?.facts, + ).toContainEqual({ + kind: "unity-il2cpp-backup-convention", + offset: null, + value: { backup_directory: "." }, + }); + const ordinary = await inspect(join(parent, "Ordinary")); + expect(ordinary.report.next_probes.map((probe) => probe.operation)).toEqual([ + "inspect_managed_artifact", + ]); +}); + it("retains complete observations through cancellation during directory finalization", async () => { const root = await createTestTempDirectory("rea-desktop-finalize-cancel-"); await put(root, "notes.txt", "target content"); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index d26a2e7381..2921a6a445 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -289,6 +289,170 @@ const createSnapshotReplayFixture = async ( }; }; +describe("one-shot Ghidra pseudocode snapshots", () => { + it("keeps persistent documentless reads out of the provider cache", async () => { + const fixture = await createSnapshotReplayFixture("rea-session-code-"); + const session = createTestBinarySession( + fixture.createProvider({ + profile: createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ), + supportedOperations: ["procedure_pseudo_code"], + }), + ); + try { + expect((await session.open(fixture.path)).ok).toBe(true); + for (let index = 0; index < 2; index++) + expect( + ( + await session.execute("procedure_pseudo_code", { + procedure: "fixture", + }) + ).ok, + ).toBe(true); + const snapshot = session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + expect(snapshot.value.entries).toEqual([]); + expect(snapshot.value.evidence_bundle.records).toEqual([]); + expect( + fixture.calls.filter((name) => name === "procedure_pseudo_code"), + ).toHaveLength(2); + } finally { + expect((await session.close()).ok).toBe(true); + } + }); + + it.skipIf(process.platform === "win32")( + "replays the exact selected-target answer without opening another provider", + async () => { + const fixture = await createSnapshotReplayFixture("rea-one-shot-code-"); + const profile = createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ); + const dependencies = fixture.dependencies( + fixture.createProvider({ + profile, + supportedOperations: ["procedure_pseudo_code"], + }), + ); + const options = { snapshotPath: fixture.snapshotPath }; + const parameters = { procedure: "fixture" }; + const first = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ); + expect(first.ok).toBe(true); + const second = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + options, + ); + expect(second).toEqual(first); + expect(fixture.starts).toHaveLength(1); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toMatchObject([ + { operation: "procedure_pseudo_code", parameters }, + ]); + }, + ); + + it("does not make another provider's implicit document replayable", async () => { + const fixture = await createSnapshotReplayFixture("rea-implicit-document-"); + const dependencies = fixture.dependencies( + fixture.createProvider({ + supportedOperations: ["procedure_pseudo_code"], + }), + ); + for (let index = 0; index < 2; index++) { + const result = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + { procedure: "fixture" }, + { snapshotPath: fixture.snapshotPath }, + ); + expect(result.ok).toBe(true); + } + expect(fixture.starts).toHaveLength(2); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + expect(saved.value.entries).toEqual([]); + }); +}); + +describe("legacy one-shot Ghidra pseudocode snapshots", () => { + it.skipIf(process.platform === "win32")( + "restores a missing replay entry without replacing the original Evidence bundle", + async () => { + const fixture = await createSnapshotReplayFixture("rea-legacy-code-"); + const dependencies = fixture.dependencies( + fixture.createProvider({ + profile: createAnalysisProfile( + { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }, + { fixture: true }, + ), + supportedOperations: ["procedure_pseudo_code"], + }), + ); + const parameters = { procedure: "fixture" }; + const query = () => + runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + parameters, + { snapshotPath: fixture.snapshotPath }, + ); + const first = await query(); + if (!first.ok) throw new Error(JSON.stringify(first.error)); + const saved = await readAnalysisSnapshot(fixture.snapshotPath); + if (!saved.ok) throw saved.error; + const written = await withArtifactResourceScope((resources) => + writeAnalysisSnapshot( + { ...saved.value, entries: [] }, + fixture.snapshotPath, + true, + resources, + ), + ); + if (!written.ok) throw written.error; + const legacy = await readAnalysisSnapshot(fixture.snapshotPath); + if (!legacy.ok) throw legacy.error; + expect(legacy.value.entries).toEqual([]); + expect(legacy.value.evidence_bundle).toEqual(saved.value.evidence_bundle); + + const upgraded = await query(); + expect(upgraded).toEqual(first); + expect(fixture.starts).toHaveLength(2); + const restored = await readAnalysisSnapshot(fixture.snapshotPath); + if (!restored.ok) throw restored.error; + expect(restored.value.entries).toMatchObject([ + { operation: "procedure_pseudo_code", parameters }, + ]); + expect(restored.value.evidence_bundle).toEqual( + saved.value.evidence_bundle, + ); + + const replay = await query(); + expect(replay).toEqual(first); + if (!replay.ok) throw new Error(JSON.stringify(replay.error)); + expect(replay.value.evidence_id).toBe(first.value.evidence_id); + expect(fixture.starts).toHaveLength(2); + expect( + fixture.calls.filter((name) => name === "procedure_pseudo_code"), + ).toHaveLength(2); + }, + ); +}); + const withAlternateWorkflowProfile = ( snapshot: AnalysisSnapshot, path: string, @@ -1671,3 +1835,65 @@ it("retains a native dispatch workflow snapshot without a deep provider profile" saved.value, ); }); + +it("runs decompile export against the provider after loading a pseudocode snapshot", async () => { + const fixture = await createSnapshotReplayFixture("rea-directory-snapshot-"); + const original = fixture.createProvider({ + supportedOperations: ["list_procedures", "procedure_pseudo_code"], + }); + const provider: AnalysisProvider = { + ...original, + createClient: (...args) => { + const client = original.createClient(...args); + return { + ...client, + execute: async (operation, parameters, options) => { + const result = await client.execute(operation, parameters, options); + return result.ok && operation === "procedure_pseudo_code" + ? ok({ + ...result.value, + sourceDescriptor: { + kind: "single-function-decompile" as const, + }, + }) + : result; + }, + }; + }, + }; + const dependencies = fixture.dependencies(provider); + const first = await runDirectAnalysis( + dependencies, + fixture.path, + "procedure_pseudo_code", + { procedure: "0x1000", document: fixture.path }, + { snapshotPath: fixture.snapshotPath }, + ); + expect(first.ok).toBe(true); + const snapshot = await readAnalysisSnapshot(fixture.snapshotPath); + if (!snapshot.ok) throw snapshot.error; + expect( + snapshot.value.entries.some( + (entry) => entry.operation === "procedure_pseudo_code", + ), + ).toBe(true); + const directory = join(dirname(fixture.snapshotPath), "export"); + const exported = await runDirectAnalysis( + dependencies, + fixture.path, + "decompile_all", + { output_directory: directory, layout: "address", resume: false }, + { snapshotPath: fixture.snapshotPath }, + ); + expect(exported.ok, JSON.stringify(exported)).toBe(true); + expect( + fixture.calls.filter((name) => name === "procedure_pseudo_code"), + ).toHaveLength(2); + const index = JSON.parse( + await readFile(join(directory, "decompile-index.json"), "utf8"), + ) as { functions: Array<{ provider_source?: unknown }> }; + expect(index.functions[0]?.provider_source).toEqual({ + status: "reported", + source: { kind: "single-function-decompile" }, + }); +}); diff --git a/tests/boundary/filesystem/flutterBuildProvider.test.ts b/tests/boundary/filesystem/flutterBuildProvider.test.ts index 40aaf9093c..3c437d9535 100644 --- a/tests/boundary/filesystem/flutterBuildProvider.test.ts +++ b/tests/boundary/filesystem/flutterBuildProvider.test.ts @@ -1237,6 +1237,31 @@ it.each(["identify_flutter_build", "inspect_dart_aot"] as const)( }, ); +it("qualifies unlabeled Dart-shaped strings as candidates", async () => { + const candidate = "1.2.3 (stable) (unrelated string)"; + const path = await buildApk("dart-pattern.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, + { + path: "lib/arm64-v8a/libflutter.so", + bytes: Buffer.concat([libflutter(), Buffer.from(`\0${candidate}\0`)]), + }, + ]); + const provider = new FlutterBuildProvider(); + onTestFinished(() => provider.close()); + const result = await provider.execute({ + operation: "identify_flutter_build", + input: { path }, + }); + if (!result.ok) throw result.error; + const value = flutterResultSchemas.identify_flutter_build.parse( + result.value.result, + ); + expect(value.abis[0]?.libflutter.dart_version).toBe(candidate); + expect(result.value.limitations.join(" ")).toContain( + "a matching unrelated string may be reported", + ); +}); + it("reports omitted Dart labels independently of toolchain labels", async () => { const path = await buildApk("dart-label.apk", [ { path: "lib/arm64-v8a/libapp.so", bytes: aotImage() }, diff --git a/tests/boundary/filesystem/harmony/application.test.ts b/tests/boundary/filesystem/harmony/application.test.ts index 1e3718734a..1406bcb14f 100644 --- a/tests/boundary/filesystem/harmony/application.test.ts +++ b/tests/boundary/filesystem/harmony/application.test.ts @@ -6,6 +6,7 @@ import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { fragmentInventoryEvidence } from "../../../fixtures/artifactEvidence.js"; import { createNonApplicationZipInventory, inventoryApplicationPackage, @@ -18,6 +19,7 @@ import { projectHarmonyApplicationEvidence } from "../../../../src/application/h import { createDirectAnalysis } from "../../../../src/composition/directAnalysis.js"; import { harmonyApplicationProjectionResultSchema } from "../../../../src/domain/harmony/harmonyApplication.js"; import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import { createEvidence } from "../../../../src/domain/evidence.js"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ZIP_PACKAGE_FORMATS, @@ -37,6 +39,62 @@ const stageHapEntries = [ { path: "META-INF/FIXTURE.p7b", content: "opaque signing" }, ] as const; +describe("HarmonyOS unclassified inventory", () => { + it("retains distinct unclassified paths sharing content with a classified file", async () => { + const path = await writeApplicationZip( + "rea-harmony-unclassified-", + "Fixture.hap", + [ + { path: "module.json", content: "{}" }, + { path: "pkgContextInfo.json", content: "{}" }, + { path: ".pages.info", content: "{}" }, + { path: "metadata-copy.json", content: "{}" }, + { path: "resources/base/bridge.js", content: "bridge();" }, + ], + ); + const inventory = await inventoryApplicationPackage(path); + const original = JSON.stringify(inventory); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + inventory, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.unclassified_components).toEqual([ + expect.objectContaining({ + path: ".pages.info", + sha256: + "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + }), + expect.objectContaining({ path: "metadata-copy.json" }), + expect.objectContaining({ path: "pkgContextInfo.json" }), + ]); + expect(projection.components.resources.map(({ path }) => path)).toEqual([ + "resources/base/bridge.js", + ]); + expect(projection.components.javascript.map(({ path }) => path)).toEqual([ + "resources/base/bridge.js", + ]); + expect(projection.coverage).toEqual({ + status: "complete-within-inventory", + inventory_complete: true, + }); + const pages = fragmentInventoryEvidence(inventory, 3); + const assembled = requireSuccessfulProjection( + projectHarmonyApplicationEvidence({ + inventory_evidence: [...pages].reverse(), + }), + ); + const fromPages = harmonyApplicationProjectionResultSchema.parse( + assembled.normalized_result, + ); + expect(fromPages.unclassified_components).toEqual( + projection.unclassified_components, + ); + expect(fromPages.coverage).toEqual(projection.coverage); + expect(JSON.stringify(inventory)).toBe(original); + }); +}); + it("retains inventoried certificate-block suffixes without claiming verified signatures", async () => { const names = ["CERT.RSA", "CERT.DSA", "CERT.EC", "FIXTURE.p7b"]; const path = await writeApplicationZip( @@ -147,6 +205,7 @@ describe("HarmonyOS application projection", () => { }), ]); expect(left.app_pack_children).toEqual([]); + expect(left.unclassified_components).toEqual([]); expect(JSON.stringify(left)).not.toContain("opaque signing"); expect(left.limitations).toContain( "Manifest, module, resource, and signing semantics require a dedicated HarmonyOS provider; this projection reports exact inventory paths and hashes only.", @@ -189,6 +248,7 @@ describe("HarmonyOS application projection", () => { expect(projection.app_pack_children.map(({ path }) => path).sort()).toEqual( ["entry.hap", "feature.hsp"], ); + expect(projection.unclassified_components).toEqual([]); expect(projection.packaging_model).toBe("unknown"); expect(projection.limitations).toContain( "App Pack child packages are listed by inventory path only; their contents are not recursively inventoried by this projection.", @@ -312,6 +372,76 @@ describe("HarmonyOS packaging boundaries", () => { }); }); +describe("HarmonyOS partial inventory classification", () => { + it("does not claim complete classification from a missing inventory occurrence", async () => { + const path = await writeApplicationZip( + "rea-harmony-partial-", + "Fixture.hap", + [ + { path: "module.json", content: "{}" }, + { path: ".pages.info", content: "page metadata" }, + ], + ); + const inventory = await inventoryApplicationPackage(path); + if (inventory.subject === null) throw new Error("Missing fixture subject"); + const result = artifactInventoryResultSchema.parse( + inventory.normalized_result, + ); + const partial = createEvidence( + { + path: inventory.subject.local_path, + sha256: inventory.subject.digest.sha256, + format: inventory.subject.format, + }, + inventory.provider, + { + operation: inventory.operation, + parameters: inventory.parameters, + result: { + ...result, + occurrences: result.occurrences.filter( + ({ logical_path }) => logical_path !== ".pages.info", + ), + }, + confidence: "derived", + authority: inventory.authority, + }, + ); + const projection = requireDeterministicProjection( + projectHarmonyApplicationEvidence, + partial, + harmonyApplicationProjectionResultSchema, + ); + expect(projection.unclassified_components).toEqual([]); + // Coverage is the structured fact a partial inventory reports, and the two + // independent causes of a partial projection stay empty, so `partial` is + // attributable to the missing occurrence alone. The prose limitation that + // accompanies it is explanatory wording, so it stays in review rather than + // in a substring assertion. + expect(projection.coverage).toEqual({ + status: "partial", + inventory_complete: false, + }); + expect(projection.unavailable_components).toEqual([]); + expect(projection.bridge_candidate_coverage).toEqual({ + status: "complete", + total_candidates: 0, + emitted_candidates: 0, + omitted_candidates: 0, + }); + const tampered = { + ...partial, + normalized_result: inventory.normalized_result, + }; + expect( + projectHarmonyApplicationEvidence({ inventory_evidence: [tampered] }), + ).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + }); +}); + describe("HarmonyOS inventory coverage", () => { it("retains complete traversal and case-collision limits in the application projection", async () => { const path = await writeApplicationZip( @@ -375,6 +505,9 @@ describe("HarmonyOS inventory coverage", () => { await writer.add("ets/modules.abc", new TextReader("opaque bytecode"), { password: "fixture-only", }); + await writer.add("pkgContextInfo.json", new TextReader("{}"), { + password: "fixture-only", + }); await writeFile(path, await writer.close()); const evidence = await inventoryApplicationPackage(path); const projection = requireDeterministicProjection( @@ -383,6 +516,7 @@ describe("HarmonyOS inventory coverage", () => { harmonyApplicationProjectionResultSchema, ); expect(projection.components.bytecode).toEqual([]); + expect(projection.unclassified_components).toEqual([]); expect(projection.unavailable_components).toEqual([ expect.objectContaining({ path: "ets/modules.abc", @@ -390,6 +524,12 @@ describe("HarmonyOS inventory coverage", () => { sha256: null, encrypted: true, }), + expect.objectContaining({ + path: "pkgContextInfo.json", + artifact_id: null, + sha256: null, + encrypted: true, + }), ]); expect(projection.coverage).toEqual({ status: "partial", @@ -421,6 +561,11 @@ describe("HarmonyOS manifest path rules", () => { ); expect(projection.packaging_model).toBe("unknown"); expect(projection.components.manifests).toEqual([]); + // Exact-case manifest names only, so these members belong to no manifest + // category and are disclosed as unclassified rather than silently dropped. + expect( + projection.unclassified_components.map(({ path }) => path).sort(), + ).toEqual(["CONFIG.JSON", "MODULE.JSON", "PACK.INFO"]); }); it.each([ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts index 374dcb034b..86ffd5efdf 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactGraphEndpoints.test.ts @@ -115,7 +115,7 @@ it("preserves partial and exact endpoint identity through graph projection", asy ); await writeFile( join(root, "app.js"), - 'fetch(target); fetch("target"); function other(target) { fetch(target); }', + 'fetch(target); fetch("target"); fetch(new Request("/api/chat", { method: "POST" })); function other(target) { fetch(target); }', ); const snapshot = await scanCanonicalArtifactInventory(root, {}); const reader = createJavaScriptArtifactReader(root, "directory"); @@ -154,6 +154,22 @@ it("preserves partial and exact endpoint identity through graph projection", asy limitations: [expect.stringContaining("not an exact literal")], }, }); + const request = endpoints.find(({ observations }) => + observations.some(({ properties }) => properties.value === "/api/chat"), + ); + expect(request?.observations[0]).toMatchObject({ + properties: { + endpoint_kind: "network", + value: "/api/chat", + mechanism: "call:fetch", + }, + evidence: { + authority: "ast-static-analysis", + state: "observed", + confidence: "exact", + }, + }); + expect(request?.observations[0]?.properties.resolution).toBeUndefined(); } finally { await reader.close(); await rm(root, { recursive: true, force: true }); diff --git a/tests/boundary/filesystem/keyedArchiveSelection.test.ts b/tests/boundary/filesystem/keyedArchiveSelection.test.ts index 90518433c9..d6c862d81a 100644 --- a/tests/boundary/filesystem/keyedArchiveSelection.test.ts +++ b/tests/boundary/filesystem/keyedArchiveSelection.test.ts @@ -13,6 +13,8 @@ import { build } from "plist"; import { expect, it } from "vitest"; import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js"; +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; @@ -402,3 +404,49 @@ const asAnalysisError = (value: unknown): AnalysisError => { if (!(value instanceof AnalysisError)) throw value; return value; }; + +it.skipIf(process.platform === "win32")( + "reads bundle-relative archives from a bundle whose executable is a plist", + async () => { + const root = await createTestTempDirectory("rea-keyed-plist-bundle-"); + const bundlePath = join(root, "Fixture.app"); + await mkdir(join(bundlePath, "Contents", "Resources"), { recursive: true }); + await writeFile( + join(bundlePath, "Info.plist"), + "CFBundleExecutableSettings.plist", + ); + await writeFile( + join(bundlePath, "Settings.plist"), + build({ setting: "value" }), + ); + await writeFile( + join(bundlePath, "Contents", "Resources", "Model.plist"), + Buffer.from(archive), + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([], "auto", () => true), + [new ArtifactProvider(process.env, "darwin")], + ); + try { + const opened = await session.open(bundlePath); + if (!opened.ok) throw opened.error; + expect(session.activeTarget()).toMatchObject({ + kind: "artifact", + format: "plist", + bundleInfoPlist: join(bundlePath, "Info.plist"), + }); + const inspected = await session.execute("inspect_keyed_archive", { + path: "Contents/Resources/Model.plist", + }); + if (!inspected.ok) throw inspected.error; + expect(inspected.value.result).toMatchObject({ + archive_path: "Contents/Resources/Model.plist", + objects: expect.arrayContaining([ + expect.objectContaining({ value: "value" }), + ]), + }); + } finally { + await session.close(); + } + }, +); diff --git a/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts b/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts new file mode 100644 index 0000000000..1be933b4b6 --- /dev/null +++ b/tests/boundary/filesystem/lmStudioClientConfiguration.test.ts @@ -0,0 +1,201 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; +import { currentClientRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; +import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; +import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { systemUninstallHost } from "../../../src/application/Uninstall.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; + +const command = currentClientRegistrationCommand(); + +beforeEach(clearClientLocationEnvironment); +afterEach(() => vi.unstubAllEnvs()); + +const lmStudioClient = (home: string) => + supportedClients(home, process.platform, {}).find( + ({ name }) => name === "lm_studio", + ); + +const exists = async (path: string): Promise => { + try { + await access(path); + return true; + } catch { + return false; + } +}; + +const expectedRegistration = (profile = "compact") => ({ + command: command[0], + args: command.slice(1), + env: { REA_MCP_INPUT_SCHEMA_PROFILE: profile }, +}); + +const doctorState = async (home: string) => + ( + await readClientRegistrationStatuses(home, undefined, { environment: {} }) + ).find(({ client }) => client === "lm_studio"); + +/** An installed LM Studio home; the pointer and cache layouts are opt-in. */ +const installedHome = async (layout: "default" | "pointer" | "cache") => { + const home = await createTestTempDirectory("rea-lm-studio-"); + let directory = join(home, ".lmstudio"); + if (layout === "pointer") { + directory = await createTestTempDirectory("rea-lm-studio-target-"); + await writeFile(join(home, ".lmstudio-home-pointer"), `${directory}\n`); + } + if (layout === "cache") directory = join(home, ".cache", "lm-studio"); + await mkdir(directory, { recursive: true }); + const client = lmStudioClient(home); + if (client === undefined) throw new Error("missing LM Studio client"); + return { home, directory, client }; +}; + +describe("LM Studio home resolution", () => { + it.each(["default", "pointer", "cache"] as const)( + "resolves mcp.json in the %s home", + async (layout) => { + const { directory, client } = await installedHome(layout); + expect(client).toEqual({ + name: "lm_studio", + displayName: "LM Studio", + configPath: join(directory, "mcp.json"), + markerPath: directory, + format: "lm_studio", + }); + }, + ); + + it("prefers the home pointer over an existing cache directory", async () => { + const { home, directory } = await installedHome("pointer"); + await mkdir(join(home, ".cache", "lm-studio"), { recursive: true }); + expect(lmStudioClient(home)?.markerPath).toBe(directory); + }); + + it("reports a relative home pointer instead of guessing a home", async () => { + const home = await createTestTempDirectory("rea-lm-studio-relative-"); + await writeFile(join(home, ".lmstudio-home-pointer"), "lmstudio\n"); + expect(lmStudioClient(home)).toMatchObject({ + configPath: join(home, ".lmstudio-home-pointer"), + configPathError: expect.stringContaining("absolute"), + }); + }); +}); + +describe("LM Studio registration lifecycle", () => { + it.each(["default", "pointer", "cache"] as const)( + "registers rea with the compact profile in the %s home", + async (layout) => { + const { home, directory, client } = await installedHome(layout); + const pointer = join(home, ".lmstudio-home-pointer"); + const pointerBefore = (await exists(pointer)) + ? await readFile(pointer, "utf8") + : undefined; + + expect( + (await configureClientConfiguration(client, {}, command)).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(join(directory, "mcp.json"), "utf8")), + ).toEqual({ mcpServers: { rea: expectedRegistration() } }); + expect(await doctorState(home)).toMatchObject({ + state: "aligned", + command, + }); + expect( + (await exists(pointer)) ? await readFile(pointer, "utf8") : undefined, + ).toBe(pointerBefore); + }, + ); + + it("preserves sibling servers, backs up, is idempotent, and uninstalls only rea", async () => { + const { home, client } = await installedHome("default"); + const original = `{ + "mcpServers": { + "other": { "command": "other-server", "args": ["--stdio"], "cwd": "/srv" } + } +} +`; + await writeFile(client.configPath, original); + + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "configured", + backupPath: `${client.configPath}.rea.backup`, + }); + const text = await readFile(client.configPath, "utf8"); + expect(JSON.parse(text)).toEqual({ + mcpServers: { + other: { command: "other-server", args: ["--stdio"], cwd: "/srv" }, + rea: expectedRegistration(), + }, + }); + expect(text).not.toContain('"type"'); + expect(text).not.toContain('"timeout"'); + expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe( + original, + ); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await readFile(client.configPath, "utf8")).toBe(text); + expect(await doctorState(home)).toMatchObject({ state: "aligned" }); + + expect((await systemUninstallHost(home).removeClient(client)).status).toBe( + "removed", + ); + expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({ + mcpServers: { + other: { command: "other-server", args: ["--stdio"], cwd: "/srv" }, + }, + }); + }); + + it("keeps an explicit full profile and reports an omitted profile stale", async () => { + const { home, client } = await installedHome("default"); + await writeFile( + client.configPath, + JSON.stringify({ + mcpServers: { rea: { command: command[0], args: command.slice(1) } }, + }), + ); + expect(await doctorState(home)).toMatchObject({ state: "stale" }); + + expect( + ( + await configureClientConfiguration( + client, + { REA_MCP_INPUT_SCHEMA_PROFILE: "full" }, + command, + ) + ).status, + ).toBe("configured"); + expect( + JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea, + ).toEqual(expectedRegistration("full")); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "unchanged", + }); + expect(await doctorState(home)).toMatchObject({ state: "aligned" }); + }); + + it.each([ + '{"mcpServers": {', + '{/* comment */ "mcpServers": {}}', + '{"mcpServers": {},}', + '{"mcpServers": {}}', + ])("rejects invalid strict JSON %j without writes", async (original) => { + const { client } = await installedHome("default"); + await writeFile(client.configPath, original); + expect(await configureClientConfiguration(client, {}, command)).toEqual({ + status: "failed", + reason: "readback", + }); + expect(await readFile(client.configPath, "utf8")).toBe(original); + expect(await exists(`${client.configPath}.rea.backup`)).toBe(false); + }); +}); diff --git a/tests/boundary/filesystem/prReadiness.test.ts b/tests/boundary/filesystem/prReadiness.test.ts index 4d3e7273e2..8e136c9da4 100644 --- a/tests/boundary/filesystem/prReadiness.test.ts +++ b/tests/boundary/filesystem/prReadiness.test.ts @@ -30,6 +30,7 @@ const makePull = ( state: "open", draft: false, body: readyBody, + user: { id: 123456 }, head: { sha: "a".repeat(40) }, base: { ref: "main" }, ...changes, @@ -94,6 +95,30 @@ function fixture(changes: Partial = {}) { } describe("initial author declaration", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "exempts the configured author ID %s, but keeps drafts pending", + (id) => { + expect( + evaluateReadiness(makePull({ user: { id }, body: null }), template), + ).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect( + evaluateReadiness( + makePull({ user: { id }, body: null, draft: true }), + template, + ).state, + ).toBe("pending"); + }, + ); + + it("does not exempt unknown or missing author identities", () => { + for (const user of [{ id: 123456 }, null]) + expect( + evaluateReadiness(makePull({ user, body: null }), template).state, + ).toBe("failure"); + }); it("accepts the filled current template and uppercase checkmarks", () => { expect(evaluateReadiness(makePull(), template).state).toBe("success"); expect( @@ -212,6 +237,35 @@ describe("initial author declaration", () => { }); describe("GitHub status boundary", () => { + it.each([76467478, 208281322, 161784452, 342580529])( + "publishes a labeled exemption for author ID %s after checking the head", + async (id) => { + const input = fixture({ user: { id }, body: null }); + expect(await runReadinessCheck(input)).toMatchObject({ + state: "success", + description: expect.stringContaining("exempt"), + }); + expect(input.paginate).toHaveBeenCalledOnce(); + expect(input.createCommitStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ state: "success", sha: input.pull.head.sha }), + ); + }, + ); + it("does not let an exempt author lend success to another PR sharing the head", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.paginate.mockResolvedValue([input.pull, makePull({ number: 43 })]); + expect((await runReadinessCheck(input))?.state).toBe("failure"); + }); + + it("does not publish an exemption if the author changes during the check", async () => { + const input = fixture({ user: { id: 76467478 }, body: null }); + input.get + .mockResolvedValueOnce({ data: input.pull }) + .mockResolvedValueOnce({ + data: makePull({ user: { id: 123456 }, body: null }), + }); + expect((await runReadinessCheck(input))?.state).toBe("pending"); + }); it("publishes failure on the actual PR head when declarations are unchecked", async () => { const input = fixture({ body: readyBody.replaceAll("[x]", "[ ]") }); expect((await runReadinessCheck(input))?.state).toBe("failure"); diff --git a/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts new file mode 100644 index 0000000000..1f072bd554 --- /dev/null +++ b/tests/boundary/filesystem/referenceSourceGitMetadataExclusion.test.ts @@ -0,0 +1,208 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import ignore from "ignore"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS } from "../../../src/application/ReferenceSourceImportTypes.js"; +import { createHistoricalSourceManifest } from "../../../src/domain/referenceSourceGraph.js"; +import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { importReferenceSource } from "../../support/referenceSourceResourceScope.js"; + +const importTree = async ( + root: string, + secretPatterns: readonly string[] = [], + excludePaths: readonly string[] = [], +) => { + const result = await importReferenceSource({ + root, + caller: "git-metadata-test", + policy: { secretPatterns }, + excludePaths, + }); + if (!result.ok) throw result.error; + return result.value; +}; + +const fixtureGit = ( + directory: string, + configuration: string, + args: readonly string[], +) => + execFileOutput( + "git", + ["-c", `core.hooksPath=${join(configuration, "no-hooks")}`, ...args], + { + cwd: directory, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => !name.toUpperCase().startsWith("GIT_"), + ), + ), + GIT_CONFIG_GLOBAL: join(configuration, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + GIT_TEMPLATE_DIR: join(configuration, "no-template"), + }, + }, + ); + +it("excludes Git metadata files and directories without broadening directory-only defaults", () => { + const matcher = ignore().add([...DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS]); + for (const path of [".git", ".git/", "nested/.git", "nested/.git/"]) + expect(matcher.ignores(path), path).toBe(true); + for (const path of [".gitignore", ".github", ".gitkeep", "dist"]) + expect(matcher.ignores(path), path).toBe(false); + expect(matcher.ignores("dist/")).toBe(true); + expect(matcher.test(".git").rule?.pattern).toBe(".git"); + expect(matcher.test(".git/").rule?.pattern).toBe(".git/"); +}); + +describe.skipIf(process.platform === "win32")( + "reference source Git metadata exclusions", + () => { + it("keeps linked-worktree identities stable while retaining HEAD and ordinary-checkout provenance", async () => { + const root = await createTestTempDirectory("rea-reference-git-metadata-"); + const repository = join(root, "repository"); + await Promise.all([ + mkdir(join(root, "no-hooks")), + mkdir(join(root, "no-template")), + writeFile(join(root, "gitconfig"), ""), + ]); + await mkdir(join(repository, "src"), { recursive: true }); + await writeFile( + join(repository, "src", "main.js"), + "export const value = 7;\n", + ); + await writeFile(join(repository, ".gitignore"), "*.log\n"); + const git = (...args: string[]) => fixtureGit(repository, root, args); + await git("init", "--initial-branch=main"); + await git("add", ".gitignore", "src/main.js"); + await git( + "-c", + "user.name=REA fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ); + const head = (await git("rev-parse", "HEAD")).stdout.trim(); + const leftRoot = join(root, "left"); + const rightRoot = join(root, "right"); + for (const worktree of [leftRoot, rightRoot]) { + await git("worktree", "add", "--detach", worktree, head); + expect( + (await fixtureGit(worktree, root, ["status", "--porcelain"])).stdout, + ).toBe(""); + } + expect(await readFile(join(leftRoot, ".git"), "utf8")).not.toBe( + await readFile(join(rightRoot, ".git"), "utf8"), + ); + const importSelectedTree = async (source: string) => { + const graph = await importTree(source); + expect(graph.vcs).toEqual({ kind: "git", head, dirty: null }); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([".gitignore", "src/main.js"]); + expect( + graph.entries.some( + ({ path }) => path === ".git" || path.startsWith(".git/"), + ), + ).toBe(false); + return graph; + }; + const left = await importSelectedTree(leftRoot); + const right = await importSelectedTree(rightRoot); + const repeated = await importSelectedTree(leftRoot); + expect(createHistoricalSourceManifest(left)).toEqual( + createHistoricalSourceManifest(right), + ); + expect(createHistoricalSourceManifest(repeated)).toEqual( + createHistoricalSourceManifest(left), + ); + for (const graph of [left, right]) + expect(graph.exclusions).toEqual([ + { + path: ".git", + entry_kind: "file", + reason: "default-ignored", + pattern: ".git", + }, + ]); + const ordinary = await importSelectedTree(repository); + expect(ordinary.exclusions).toEqual([ + { + path: ".git", + entry_kind: "directory", + reason: "default-ignored", + pattern: ".git/", + }, + ]); + }); + + it.each(["file", "directory"] as const)( + "prunes nested .git %s metadata and preserves configured-secret precedence", + async (kind) => { + const root = await createTestTempDirectory("rea-reference-nested-git-"); + const nested = join(root, "nested"); + await mkdir(nested); + if (kind === "file") + await writeFile( + join(nested, ".git"), + "gitdir: /checkout-specific/metadata\n", + ); + else { + await mkdir(join(nested, ".git", "hooks"), { recursive: true }); + await writeFile( + join(nested, ".git", "hooks", "pre-commit"), + "metadata\n", + ); + } + for (const name of [ + ".gitignore", + ".github", + ".gitkeep", + "dist", + "source.js", + ]) + await writeFile(join(nested, name), ""); + for (const secretPatterns of [[], [".git"]]) { + const graph = await importTree( + root, + secretPatterns, + secretPatterns.length === 0 ? [] : ["!.git"], + ); + expect(graph.exclusions).toEqual([ + { + path: "nested/.git", + entry_kind: kind, + reason: + secretPatterns.length === 0 + ? "default-ignored" + : "configured-secret", + pattern: + secretPatterns.length === 0 && kind === "directory" + ? ".git/" + : ".git", + }, + ]); + expect( + graph.entries + .filter(({ kind }) => kind === "file") + .map(({ path }) => path), + ).toEqual([ + "nested/.github", + "nested/.gitignore", + "nested/.gitkeep", + "nested/dist", + "nested/source.js", + ]); + } + }, + ); + }, +); diff --git a/tests/boundary/filesystem/releaseProposal.test.ts b/tests/boundary/filesystem/releaseProposal.test.ts index f952091603..234d4967d9 100644 --- a/tests/boundary/filesystem/releaseProposal.test.ts +++ b/tests/boundary/filesystem/releaseProposal.test.ts @@ -15,6 +15,7 @@ import { z } from "zod"; import { applyReleasePleasePatch, + describeReleaseProposalError, releaseProposalSearchDepth, } from "../../../scripts/lib/release-proposal.mjs"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -33,6 +34,16 @@ const silent = { trace() {}, }; +it("reports an HTTP status when a release proposal error has no message", () => { + const failure = Object.assign(new Error(""), { + name: "HttpError", + status: 504, + request: { headers: { authorization: "secret" } }, + }); + expect(describeReleaseProposalError(failure)).toBe("HttpError (HTTP 504)"); + expect(describeReleaseProposalError(new Error("broken"))).toBe("broken"); +}); + beforeAll(async () => { upstream = await import("release-please"); githubRuntime = require("release-please/build/src/github.js"); @@ -40,6 +51,84 @@ beforeAll(async () => { afterEach(() => vi.restoreAllMocks()); +it("preserves the existing overflow notes if their replacement fails", async () => { + const write = vi + .fn() + .mockRejectedValueOnce(new Error("upload failed")) + .mockResolvedValueOnce({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const getContent = vi.fn().mockResolvedValue({ + data: { type: "file", sha: "existing-blob" }, + }); + const createNewBranch = vi.fn(); + const forkBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi.fn().mockResolvedValue("existing-branch"), + createNewBranch, + forkBranch, + octokit: { repos: { getContent, createOrUpdateFileContents: write } }, + } as unknown as Awaited>; + const update = () => + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes: café", + "notes", + "main", + ); + await expect(update()).rejects.toThrow("upload failed"); + expect(forkBranch).not.toHaveBeenCalled(); + expect(createNewBranch).not.toHaveBeenCalled(); + await expect(update()).resolves.toBe("https://example.test/notes"); + expect(write).toHaveBeenLastCalledWith( + expect.objectContaining({ + branch: "notes", + sha: "existing-blob", + content: Buffer.from("Notes: café", "utf8").toString("base64"), + }), + ); +}); + +it("creates overflow notes on a new branch without requiring an existing file", async () => { + const write = vi.fn().mockResolvedValue({ + data: { content: { html_url: "https://example.test/notes" } }, + }); + const createNewBranch = vi.fn(); + const github = { + repository: { owner: "fixture", repo: "rea" }, + getBranchSha: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("base-sha"), + createNewBranch, + octokit: { + repos: { + getContent: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("missing"), { status: 404 }), + ), + createOrUpdateFileContents: write, + }, + }, + } as unknown as Awaited>; + await expect( + upstream.GitHub.prototype.createFileOnNewBranch.call( + github, + "release-notes.md", + "Notes", + "notes", + "main", + ), + ).resolves.toBe("https://example.test/notes"); + expect(createNewBranch).toHaveBeenCalledWith("notes", "base-sha"); + expect(write).toHaveBeenCalledWith( + expect.not.objectContaining({ sha: expect.anything() }), + ); +}); + function respond(value: unknown, status = 200) { return new Response(JSON.stringify(value), { status, diff --git a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts index 1ce25fdbf2..48dd7928a0 100644 --- a/tests/boundary/filesystem/undeliveredSourceClosure.test.ts +++ b/tests/boundary/filesystem/undeliveredSourceClosure.test.ts @@ -435,6 +435,26 @@ it("reports the closure when an existing output refuses publication", async () = expect(await readFile(requested.evidenceOutput, "utf8")).toBe("original"); }); +it("reports result sources when an existing output refuses publication", async () => { + const root = await createTestTempDirectory("rea-closure-result-sources-"); + const held = source("held"); + const requested = delivery(root); + await writeFile(requested.evidenceOutput, "original"); + const output = await publishCliAnalysisEvidence( + { ...ok(observed([held.evidence_id])), sourceEvidence: [held] }, + requested, + ); + expect(output).toMatchObject({ details: { reason: "exists" } }); + expect(closureOf(output)).toMatchObject({ + publication: "not_published", + complete: true, + omitted_count: 0, + omitted: [], + }); + expect(closureOf(output).records).toEqual([held]); + expect(await readFile(requested.evidenceOutput, "utf8")).toBe("original"); +}); + it("defers to the committed bundle receipt after cancellation wins late", async () => { const root = await createTestTempDirectory("rea-closure-committed-"); const held = source("held"); diff --git a/tests/boundary/firmware/firmwarePublicationPaths.test.ts b/tests/boundary/firmware/firmwarePublicationPaths.test.ts index 61b26a3c73..e0e1fecf4c 100644 --- a/tests/boundary/firmware/firmwarePublicationPaths.test.ts +++ b/tests/boundary/firmware/firmwarePublicationPaths.test.ts @@ -188,6 +188,7 @@ it("publishes the writer's normalized path while retaining source spelling and u staging, 1024, 10, + Number.MAX_SAFE_INTEGER, "extract_firmware", ), normalized, diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index c3067b89d9..02e23eb52e 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -160,6 +160,7 @@ it("orders extracted firmware paths by code point independently of host locale", root, 1024, 16, + Number.MAX_SAFE_INTEGER, "inspect_firmware_regions", ); // Locale collation would interleave case and place "ä" by language rules. @@ -186,7 +187,13 @@ it.skipIf(process.platform !== "linux")( ); await writeFile(join(root, "good.bin"), "1234"); await expect( - inventoryFirmwareOutput(root, 4, 10, "extract_firmware"), + inventoryFirmwareOutput( + root, + 4, + 10, + Number.MAX_SAFE_INTEGER, + "extract_firmware", + ), ).rejects.toMatchObject({ _tag: "AnalysisOutputError", diagnostics: { raw_name_hex: "626164ff2e62696e" }, @@ -204,7 +211,13 @@ it.each([ await writeFile(join(root, "a"), "1"); await writeFile(join(root, "b"), "2"); await expect( - inventoryFirmwareOutput(root, bytes, files, "extract_firmware"), + inventoryFirmwareOutput( + root, + bytes, + files, + Number.MAX_SAFE_INTEGER, + "extract_firmware", + ), ).rejects.toMatchObject({ _tag: "AnalysisResourceConstraintError", resource: "capacity", @@ -215,6 +228,23 @@ it.each([ }, ); +it("refuses a staged file that reaches the producer's per-file limit", async () => { + const root = await createTestTempDirectory("rea-firmware-file-limit-"); + await writeFile(join(root, "below"), "123"); + await expect( + inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"), + ).resolves.toEqual([expect.objectContaining({ size: 3 })]); + await writeFile(join(root, "reached"), "1234"); + await expect( + inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"), + ).rejects.toMatchObject({ + _tag: "AnalysisResourceConstraintError", + resource: "file-size", + reportedLimits: { file_size_bytes: 4 }, + retryable: false, + }); +}); + it("attributes missing sources to the selected path without relabeling snapshot destination failure", async () => { const root = await createTestTempDirectory("rea-firmware-source-"); const files = new FirmwareFileAccess(); diff --git a/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts b/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts index 84b34dea4f..d53126c6e2 100644 --- a/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts +++ b/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts @@ -37,6 +37,7 @@ it("refuses staging bytes at the schema ceiling with the ceiling recovery", asyn root, MAX_FIRMWARE_STAGING_BYTES, MAX_FIRMWARE_STAGING_ENTRIES, + Number.MAX_SAFE_INTEGER, "extract_firmware", ).catch((cause: unknown) => cause); @@ -71,6 +72,7 @@ it("still offers a raise for a budget below the schema ceiling", async () => { root, 1, MAX_FIRMWARE_STAGING_ENTRIES, + Number.MAX_SAFE_INTEGER, "extract_firmware", ).catch((cause: unknown) => cause); diff --git a/tests/boundary/firmware/resourceLimits.test.ts b/tests/boundary/firmware/resourceLimits.test.ts new file mode 100644 index 0000000000..390781238a --- /dev/null +++ b/tests/boundary/firmware/resourceLimits.test.ts @@ -0,0 +1,60 @@ +import { access } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { firmwareResultSchemas } from "../../../src/domain/firmware/firmwareAnalysis.js"; +import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js"; +import { firmwareFileSizeService } from "../../fixtures/firmware/fileSizeProducer.js"; +import { assertFirmwareCleanup } from "../../fixtures/firmware/provider.js"; + +const linux = it.skipIf(process.platform !== "linux"); +const budget = 16 * 1024 * 1024; +const limits = { + probeFileBytes: FIRMWARE_LIMITS.reportBytes + 1, + extractionFileBytes: budget + 1, +}; + +linux("publishes a staged file of exactly the staging budget", async () => { + const fixture = await firmwareFileSizeService({ + ...limits, + attemptedBytes: budget, + }); + const outcome = await fixture.service.execute("extract_firmware", { + path: fixture.path, + output_directory: fixture.output, + max_output_bytes: budget, + }); + if (!outcome.ok) throw outcome.error; + const result = firmwareResultSchemas.extract_firmware.parse( + outcome.value.normalized_result, + ); + expect(result.files).toEqual([ + expect.objectContaining({ relative_path: "rootfs/config", size: budget }), + ]); + await assertFirmwareCleanup(fixture.launches); +}); + +linux( + "rejects a staged file the kernel stops at the per-file limit", + async () => { + const fixture = await firmwareFileSizeService({ + ...limits, + attemptedBytes: budget + 4096, + }); + const outcome = await fixture.service.execute("extract_firmware", { + path: fixture.path, + output_directory: fixture.output, + max_output_bytes: budget, + }); + expect(outcome).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "capacity", + reportedLimits: { max_output_bytes: budget }, + }, + }); + await expect(access(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + await assertFirmwareCleanup(fixture.launches); + }, +); diff --git a/tests/boundary/go/goBinaryProvider.test.ts b/tests/boundary/go/goBinaryProvider.test.ts index 077f2ecbf9..cfda1f065e 100644 --- a/tests/boundary/go/goBinaryProvider.test.ts +++ b/tests/boundary/go/goBinaryProvider.test.ts @@ -131,7 +131,12 @@ it.skipIf(process.platform === "win32")( await symlink(original, selected); const result = await new GoBinaryProvider().inspect({ path: selected }); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisInputError); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisInputError); + const projected = projectAnalysisError(result.error); + expect(projected.remediation.action).toContain(original); + expect(projected).not.toHaveProperty("recovery"); + } }, ); diff --git a/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts new file mode 100644 index 0000000000..c56840195b --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeNodeTarget.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "vitest"; +import { reconcileJavaScriptRuntimeEvidence } from "../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; +import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { nodeReconciliationFixture } from "../../fixtures/nodeRuntimeReconciliation.js"; + +test.each([false, true])( + "matches a synthetic Node entry target without inventing script presence: %s", + (scriptObserved) => { + const reconciled = reconcileJavaScriptRuntimeEvidence( + nodeReconciliationFixture(scriptObserved), + ); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.evidence.normalized_result, + ); + const target = result.reconciliations.find( + ({ entity_kind }) => entity_kind === "target", + ); + expect(target).toMatchObject({ + status: "matched", + basis: "artifact-path", + confidence: "medium", + reason: "unique-location-match", + candidate_static_count: 1, + }); + expect( + result.graph.nodes.find( + ({ node_id }) => node_id === target?.static_node_id, + )?.kind, + ).toBe("javascript-asset"); + expect(result.summary).toMatchObject({ + runtime_scripts: scriptObserved ? 1 : 0, + static_loaded: scriptObserved ? 1 : 0, + static_not_observed: 0, + }); + }, +); diff --git a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts index ef1c99e458..11b78f9819 100644 --- a/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts +++ b/tests/boundary/javascript/javascriptStaticProjectionReclamation.test.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import { expect, it, vi } from "vitest"; +import { z } from "zod"; const heap = vi.hoisted(() => ({ + usedBytes: 1024 * 1024, remainingBytes: 200 * 1024 * 1024, readings: [] as number[], })); @@ -13,9 +15,9 @@ vi.mock("node:v8", async (load) => { const current = actual.getHeapStatistics(); return { ...current, + used_heap_size: heap.usedBytes, heap_size_limit: - current.used_heap_size + - (heap.readings.shift() ?? heap.remainingBytes), + heap.usedBytes + (heap.readings.shift() ?? heap.remainingBytes), }; }, }; @@ -37,32 +39,56 @@ vi.mock( import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; -const file = (path: string, count: number): JavaScriptArtifactFile => { +const sourceText = (text: string) => ({ + sha256: createHash("sha256").update(text).digest("hex"), + bytes: Buffer.byteLength(text), + text: { included: true as const, value: text }, +}); +const file = (path: string, count: number): JavaScriptArtifactFile => ({ + path, + container_sha256: "a".repeat(64), + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + ...sourceText( + Array.from( + { length: count }, + (_, i) => `localStorage.setItem('k${i}', 'v');`, + ).join("\n"), + ), +}); +const expectCapacityRemains = ( + result: Awaited>, +): void => { + if ( + !result.ok && + result.error.error instanceof AnalysisResourceConstraintError + ) + expect( + result.error.error.reportedLimits?.parent_result_budget_exhausted, + ).not.toBe(true); +}; +const functions = (count: number): JavaScriptArtifactFile => { const text = Array.from( { length: count }, - (_, i) => `localStorage.setItem('k${i}', 'v');`, + (_, i) => `export function f${i}(a) { const b = a + ${i}; return b; }`, ).join("\n"); - return { - path, - container_sha256: "a".repeat(64), - sha256: createHash("sha256").update(text).digest("hex"), - bytes: Buffer.byteLength(text), - inventory_artifact_id: path, - kind: "javascript", - unpacked: false, - text: { included: true, value: text }, - }; + return { ...file("functions.js", 0), ...sourceText(text) }; }; +// The mocked heap leaves a 4 MiB result budget and 6 MiB of static capacity. +// A 50-line source reserves about 1.1 MiB of relationships; each later 500-line +// source is charged about 0.5 MiB, so reclamation starts at the eleventh. +const SOURCES_UNTIL_RECLAMATION = 12; it("uses one live heap observation for revocation and static admission", async () => { const run = async (changeHeapAt?: number) => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) { + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) { const index = i; results.push( await worker.analyze( @@ -108,15 +134,15 @@ it("uses one live heap observation for revocation and static admission", async ( ).not.toBe(true); }); -it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the result budget", async () => { +it("reclaims prior unmaterialized projections only when a complete static checkpoint would otherwise exhaust the static capacity", async () => { const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 8, + SOURCES_UNTIL_RECLAMATION, ); const results = []; try { - for (let i = 0; i < 8; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { nodes: 1, @@ -149,13 +175,13 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", const worker = new JavaScriptAnalysisWorker( { heapMb: 512, timeoutMs: 30_000 }, undefined, - 5, + 6, ); try { const results = []; - for (let i = 0; i < 5; i++) + for (let i = 0; i < 6; i++) results.push( - await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1400), { nodes: 1, unknowns: 1, }), @@ -173,9 +199,136 @@ it("keeps earlier projections when reclaiming them cannot admit the checkpoint", if (last === undefined || last.ok) throw new Error("Expected capacity refusal"); expect(last.error.error).toMatchObject({ - reportedLimits: { parent_result_budget_exhausted: true }, + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 6 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, + }); + const { + static_retention_estimate_bytes: required, + parent_available_static_capacity_bytes: available, + } = z + .object({ + static_retention_estimate_bytes: z.number(), + parent_available_static_capacity_bytes: z.number(), + }) + .parse( + last.error.error instanceof AnalysisResourceConstraintError + ? last.error.error.reportedLimits + : null, + ); + expect(required).toBeGreaterThan(available); + } finally { + await worker.close(); + } +}); + +it("retains static facts past the result budget, keeping earlier relationships while the static capacity lasts", async () => { + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const details = []; + for (let i = 0; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, i === 0 ? 50 : 500), { + nodes: 1, + unknowns: 1, + }); + expectCapacityRemains(result); + details.push(result.ok ? result.value : result.error); + } + expect(details.map((detail) => detail.javascript?.storage.length)).toEqual( + details.map((_, i) => (i === 0 ? 50 : 500)), + ); + expect( + details.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + // Relationships that do not fit the budget are refused, explicitly. + expect(details[0]?.applicationProjectionFailure).toBeUndefined(); + for (const detail of details.slice(1)) + expect(detail.applicationProjectionFailure).toMatchObject({ + reason: expect.stringContaining("exceed the parent's result budget"), + }); + } finally { + await worker.close(); + } +}); + +it("retains static facts for later sources after semantic results use the result budget", async () => { + const sources = 4; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + // Semantic results are already adopted, so nothing can be reclaimed from them. + const first = await worker.analyze(functions(10), { + nodes: 20_000, + unknowns: 20_000, + }); + if (!first.ok) throw first.error.error; + expect(first.value.module?.callables).toHaveLength(10); + expect(first.value.projection).not.toBeNull(); + const later = []; + for (let i = 1; i < sources; i++) { + const result = await worker.analyze(file(`${i}.js`, 1000), { + nodes: 20_000, + unknowns: 20_000, + }); + expectCapacityRemains(result); + later.push(result.ok ? result.value : result.error); + } + expect(later.map((detail) => detail.javascript?.storage.length)).toEqual( + later.map(() => 1000), + ); + expect( + later.flatMap((detail) => detail.revokedStaticProjections ?? []), + ).toEqual([]); + } finally { + await worker.close(); + } +}); + +it("stays within the result budget when the live heap has no room for another copy", async () => { + // Each later checkpoint is charged about 1 MiB, against 8 MiB of live room. + heap.usedBytes = 7.5 * 1024 * 1024; + const sources = 9; + const worker = new JavaScriptAnalysisWorker( + { heapMb: 512, timeoutMs: 30_000 }, + undefined, + sources, + ); + try { + const results = []; + for (let i = 0; i < sources; i++) + results.push( + await worker.analyze(file(`${i}.js`, i === 0 ? 10 : 1000), { + nodes: 1, + unknowns: 1, + }), + ); + const stopped = results + .flatMap((result) => (result.ok ? [] : [result.error.error])) + .find( + (error) => + error instanceof AnalysisResourceConstraintError && + error.reportedLimits?.parent_result_budget_exhausted === true, + ); + expect(stopped).toMatchObject({ + reportedLimits: { + parent_result_budget_bytes: 4 * 1024 * 1024, + parent_static_capacity_bytes: 4 * 1024 * 1024, + parent_used_heap_bytes: 7.5 * 1024 * 1024, + parent_result_budget_exhausted: true, + }, }); } finally { + heap.usedBytes = 1024 * 1024; await worker.close(); } }); @@ -192,9 +345,11 @@ it("resets projection reservations and source indices when an idle worker is reu unknowns: 1, }); expect(first.ok).toBe(true); - expect(worker.prepareForAnotherAnalysis(8)).toBe(true); + expect(worker.prepareForAnotherAnalysis(SOURCES_UNTIL_RECLAMATION)).toBe( + true, + ); const results = []; - for (let i = 0; i < 7; i++) + for (let i = 0; i < SOURCES_UNTIL_RECLAMATION; i++) results.push( await worker.analyze(file(`new-${i}.js`, i === 0 ? 50 : 500), { nodes: 1, diff --git a/tests/boundary/mcp/addressContextInputErrors.test.ts b/tests/boundary/mcp/addressContextInputErrors.test.ts index b908f51872..02ecb7473a 100644 --- a/tests/boundary/mcp/addressContextInputErrors.test.ts +++ b/tests/boundary/mcp/addressContextInputErrors.test.ts @@ -7,6 +7,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; import { inspectAddressContext } from "../../../src/application/AnalysisContextQueries.js"; import { + AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisProtocolError, } from "../../../src/domain/analysisErrorCore.js"; @@ -15,6 +16,225 @@ import { err, ok } from "../../../src/domain/result.js"; import { SESSION_TOOL_CONTRACTS } from "../../../src/contracts/sessionToolContracts.js"; import { createServer } from "../../../src/server/createServer.js"; +const createContextRecoverySession = () => { + const identity = { id: "fixture", name: "Fixture", version: "1" }; + return createTestBinarySession(() => ({ + execute: async (operation, parameters) => { + if (operation === "health") + return ok(createAnalysisExecution(null, identity)); + if (operation === "comment" && parameters.address === "0x405000") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "configuration unavailable", + { + remediationAction: "Restart REA.", + continuingCallerGuidance: { + remediationAction: "Refresh the current session.", + }, + }, + ), + ); + if ( + operation === "resolve_containing_procedure" && + parameters.address === "0x404000" + ) + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "not supported", + ), + ); + if (operation === "comment" && parameters.address === "0x403000") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "temporarily unavailable", + { + retryable: true, + remediationAction: "Wait for the fixture to become ready.", + }, + ), + ); + if (operation === "comment" || operation === "inline_comment") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "not supported", + ), + ); + if ( + operation !== "resolve_containing_procedure" && + operation !== "address_name" && + operation !== "list_bookmarks" + ) + throw new Error(`Unexpected provider operation: ${operation}`); + const result = + operation === "resolve_containing_procedure" + ? parameters.address === "0x402000" + ? { + query_address: "0x402000", + found: false, + procedure: null, + reason: "not_in_procedure", + } + : { + query_address: parameters.address, + found: true, + procedure: { + address: "0x401000", + name: "entry", + classification: null, + body: { available: false, reason: "not recorded" }, + }, + } + : operation === "address_name" + ? "entry" + : []; + return ok(createAnalysisExecution(result, identity)); + }, + close: async () => ok(null), + })); +}; + +it.each(["compact", "full"] as const)( + "offers a conditional containing-function comment route (%s)", + async (profile) => { + const directory = await createTestTempDirectory("rea-context-comment-"); + const target = join(directory, "fixture.com"); + await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21])); + const session = createContextRecoverySession(); + const server = createServer( + { kind: "session", session }, + { + environment: { + ...process.env, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + REA_MCP_TOOL_FAMILIES: "session", + }, + }, + ); + const client = new Client({ + name: "context-comment-recovery", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + expect( + ( + await client.callTool({ + name: "open_binary", + arguments: { path: target, format: "dos-com" }, + }) + ).isError, + ).not.toBe(true); + const response = await client.callTool({ + name: "inspect_address_context", + arguments: { address: "0x401001", document: "fixture" }, + }); + const contract = SESSION_TOOL_CONTRACTS.find( + (candidate) => candidate.name === "inspect_address_context", + ); + if (contract === undefined) + throw new Error("inspect_address_context contract is missing"); + const result = contract.outputSchema.parse( + response.structuredContent, + ).result; + for (const facet of [result.comment, result.inline_comment]) { + expect(facet).toMatchObject({ + state: "unavailable", + remediation: expect.stringContaining('"procedure":"0x401000"'), + }); + if (facet.state !== "unavailable") + throw new Error("expected unavailable facet"); + expect(facet.remediation).toContain("same document"); + expect(facet.remediation).toContain("exact requested address"); + expect(facet.remediation).toContain("parent_evidence_id"); + expect(facet.remediation).toContain("inspect_analysis_view"); + } + const outside = await client.callTool({ + name: "inspect_address_context", + arguments: { address: "0x402000", document: "fixture" }, + }); + const outsideResult = contract.outputSchema.parse( + outside.structuredContent, + ).result; + expect(outsideResult.procedure).toMatchObject({ + state: "available", + value: { + query_address: "0x402000", + found: false, + procedure: null, + reason: "not_in_procedure", + }, + }); + expect(outsideResult.comment).toMatchObject({ + state: "unavailable", + remediation: expect.stringContaining("binary_session"), + }); + if (outsideResult.comment.state !== "unavailable") + throw new Error("expected unavailable facet"); + expect(outsideResult.comment.remediation).toContain("detail complete"); + expect(outsideResult.comment.remediation).toContain("search_procedures"); + expect(outsideResult.comment.remediation).not.toContain( + "analyze_function", + ); + const transient = await client.callTool({ + name: "inspect_address_context", + arguments: { address: "0x403000", document: "fixture" }, + }); + expect(transient.isError, JSON.stringify(transient)).not.toBe(true); + const transientResult = contract.outputSchema.parse( + transient.structuredContent, + ).result; + expect(transientResult.comment).toMatchObject({ + state: "unavailable", + remediation: "Wait for the fixture to become ready.", + }); + const continuing = await client.callTool({ + name: "inspect_address_context", + arguments: { address: "0x405000", document: "fixture" }, + }); + const continuingResult = contract.outputSchema.parse( + continuing.structuredContent, + ).result; + expect(continuingResult.comment).toMatchObject({ + state: "unavailable", + remediation: "Refresh the current session.", + }); + const unsupportedProcedure = await client.callTool({ + name: "inspect_address_context", + arguments: { address: "0x404000", document: "fixture" }, + }); + const unsupportedResult = contract.outputSchema.parse( + unsupportedProcedure.structuredContent, + ).result; + expect(unsupportedResult.procedure).toMatchObject({ + state: "unavailable", + remediation: expect.stringContaining("binary_session"), + }); + if (unsupportedResult.procedure.state !== "unavailable") + throw new Error("expected unavailable facet"); + expect(unsupportedResult.procedure.remediation).toContain( + "detail complete", + ); + expect(unsupportedResult.procedure.remediation).toContain( + "search_procedures", + ); + } finally { + await client.close(); + await server.close(); + } + }, +); + it.each([ ["compact", false, "comment"], ["compact", true, "comment"], diff --git a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts index 572088d229..18581cc7d2 100644 --- a/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts +++ b/tests/boundary/mcp/analysisSnapshotProvenanceMcp.test.ts @@ -173,7 +173,10 @@ for (const missingSource of [false, true]) expect(response.isError, JSON.stringify(response.content)).not.toBe(true); const replayed = parseEvidence(response.structuredContent); expect(replayed.normalized_result).toEqual(parent.normalized_result); - expect(replayed.evidence_links).toEqual([source.evidence_id]); + expect(replayed.evidence_links).toEqual([ + source.evidence_id, + parent.evidence_id, + ]); expect(replayed.evidence_links).not.toContain(ancestor.evidence_id); expect(session.evidenceById(replayed.evidence_id)).toEqual(replayed); expect(session.evidenceById(parent.evidence_id)).toEqual(parent); @@ -182,12 +185,23 @@ for (const missingSource of [false, true]) expect(session.evidenceById(source.evidence_id)?.evidence_links).toEqual([ ancestor.evidence_id, ]); + const savedAgain = session.exportAnalysisSnapshot(); + if (!savedAgain.ok) throw savedAgain.error; + expect(savedAgain.value.evidence_bundle.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ evidence_id: parent.evidence_id }), + expect.objectContaining({ evidence_id: replayed.evidence_id }), + ]), + ); const firstReplay = await session.execute("inspect_managed_artifact", {}); if (!firstReplay.ok) throw firstReplay.error; expect(firstReplay.value.sourceEvidence?.[0]).toBe( session.evidenceForAnalysis(source.evidence_id), ); expect(Object.isFrozen(firstReplay.value.sourceEvidence?.[0])).toBe(true); + expect(firstReplay.value.sourceEvidence?.[1]).toBe( + session.evidenceForAnalysis(parent.evidence_id), + ); if (source.subject === null) throw new Error("Expected a file-backed source"); @@ -212,6 +226,30 @@ for (const missingSource of [false, true]) ancestor.evidence_id, ]); expect(Object.isFrozen(refreshed.value.sourceEvidence?.[0])).toBe(true); + if (parent.subject === null) + throw new Error("Expected a file-backed parent"); + const relocatedParent = { + ...parent, + subject: { + ...parent.subject, + local_path: join(directory, "relocated-parent.exe"), + }, + }; + const importedParent = session.importEvidenceBundle( + createEvidenceBundle([relocatedParent]), + ); + if (!importedParent.ok) throw importedParent.error; + const refreshedParent = await session.execute( + "inspect_managed_artifact", + {}, + ); + if (!refreshedParent.ok) throw refreshedParent.error; + expect(refreshedParent.value.sourceEvidence?.[1]).toBe( + session.evidenceForAnalysis(parent.evidence_id), + ); + expect(refreshedParent.value.sourceEvidence?.[1]).toEqual( + relocatedParent, + ); const refreshedResponse = await client.callTool({ name: "inspect_managed_artifact", arguments: {}, @@ -222,7 +260,7 @@ for (const missingSource of [false, true]) ).not.toBe(true); expect( parseEvidence(refreshedResponse.structuredContent).evidence_links, - ).toEqual([source.evidence_id]); + ).toEqual([source.evidence_id, parent.evidence_id]); expect(session.evidenceById(source.evidence_id)).toEqual(relocated); expect((await readFile(snapshotPath)).equals(historicalBytes)).toBe(true); }, diff --git a/tests/boundary/mcp/batchDecompileViewsMcp.test.ts b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts index ecbe90b383..b5174e596c 100644 --- a/tests/boundary/mcp/batchDecompileViewsMcp.test.ts +++ b/tests/boundary/mcp/batchDecompileViewsMcp.test.ts @@ -303,8 +303,8 @@ it.each(["full", "compact"] as const)( { index: 3, address: "emoji" }, { index: 4, - status: "error", - error: { code: "capability_unavailable" }, + status: "unavailable", + reason: "no_pseudocode", limitations: expect.arrayContaining([NO_BODY_REASON]), }, { index: 5, procedure: { status: "unknown" } }, @@ -316,7 +316,8 @@ it.each(["full", "compact"] as const)( [{ procedure_addresses: ["0x401000"] }, [0, 1, 2]], // A selector never matches as an observed entry, including an unknown one. [{ procedure_addresses: ["missing"] }, []], - [{ statuses: ["error"] }, [4, 5]], + [{ statuses: ["unavailable"] }, [4, 5]], + [{ statuses: ["error"] }, []], [{ indices: [5, 3, 3] }, [3, 5]], [{ statuses: ["ok"], procedure_addresses: ["0x402000"] }, [3]], [{ indices: [] }, []], @@ -355,7 +356,7 @@ it.each(["full", "compact"] as const)( }); expect(selected.items).toMatchObject([ { index: 3, pseudocode: EMOJI_BODY }, - { index: 4, status: "error" }, + { index: 4, status: "unavailable" }, ]); expect(JSON.stringify(selected.items)).not.toContain('"pseudocode":null'); @@ -435,7 +436,7 @@ it("pages one body exactly without splitting a Unicode character", async () => { }), ).error; expect(await refused({ index: 4 })).toMatchObject( - invalidIssue("Item 4 has status error"), + invalidIssue("Item 4 has status unavailable"), ); expect(await refused({ index: 6 })).toMatchObject( invalidIssue("outside this batch of 6"), @@ -484,6 +485,15 @@ it("recovers oversized batches and pages through smaller selections of the paren }); const rows = await harness.call(first.name, first.arguments); expect(rowIndices(viewOf(rows.evidence))).toEqual([0, 1, 2]); + expect(viewOf(rows.evidence).items).toMatchObject([ + { status: "ok" }, + { status: "ok" }, + { + status: "unavailable", + reason: "no_pseudocode", + limitations: expect.arrayContaining([NO_BODY_REASON]), + }, + ]); expect(Buffer.byteLength(JSON.stringify(rows.result))).toBeLessThan(budget); // Several bodies that cannot fit continue with fewer rows from the same parent. @@ -573,6 +583,56 @@ it("reads the same rows from a bundle and an Evidence file without a provider", } }); +it("reads historical batch Evidence with the prior null-pseudocode error", async () => { + const harness = await connect("full"); + const savedItem = { + address: "bodyless", + procedure: { status: "resolved", address: "0x403000", name: "bodyless" }, + status: "error", + error: { + code: "capability_unavailable", + category: "unsupported_provider", + message: + "The provider returned no pseudocode for this function; this item's limitations hold that observation's caveats, including any reason the provider reported.", + retryable: false, + remediation: { + action: + "Read this item's limitations for the provider's reason before retrying; another provider may decompile this entry.", + }, + details: { + provider_id: "fixture", + operation: "procedure_pseudo_code", + reason: "provider returned null pseudocode", + }, + }, + limitations: [NO_BODY_REASON], + } as const; + const historical = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "batch_decompile", + parameters: { addresses: ["bodyless"] }, + result: { + items: [savedItem], + total: 1, + succeeded: 0, + failed: 1, + }, + confidence: "derived", + }, + ); + const result = await harness.call("inspect_analysis_view", { + source: { kind: "inline", evidence: historical }, + view: { + kind: "batch-decompile", + collection: "items", + where: { statuses: ["error"] }, + }, + }); + expect(viewOf(result.evidence).items).toEqual([{ index: 0, ...savedItem }]); +}); + it("keeps the caller's own source when a page cannot fit, without retaining its parent", async () => { const budget = 64 * 1024; const body = `int f(void) { /*${"x".repeat(budget * 2)}*/ }`; diff --git a/tests/boundary/mcp/browserSourceCoordinates.test.ts b/tests/boundary/mcp/browserSourceCoordinates.test.ts index 3724412a2f..c26384bedd 100644 --- a/tests/boundary/mcp/browserSourceCoordinates.test.ts +++ b/tests/boundary/mcp/browserSourceCoordinates.test.ts @@ -1,22 +1,21 @@ +import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; -it("reports malformed source coordinates as unknown through MCP", async () => { +/** Report one call frame for a console event, a request initiator and a WebMCP registration. */ +const inspectSourceCoordinates = async (position: { + readonly lineNumber: number; + readonly columnNumber: number; +}) => { const browser = await startFakeCdpBrowser({ webMcpTools: true, webMcpStackTrace: (origin) => ({ - callFrames: [ - { url: `${origin}/app.js`, lineNumber: -3, columnNumber: 7.5 }, - ], + callFrames: [{ url: `${origin}/app.js`, ...position }], }), commandEvents(command, origin) { - const frame = { - url: `${origin}/app.js`, - lineNumber: -3, - columnNumber: 7.5, - }; + const frame = { url: `${origin}/app.js`, ...position }; const session = command.sessionId === undefined ? {} : { sessionId: command.sessionId }; if (command.method === "Runtime.enable") @@ -38,7 +37,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { ...session, method: "Network.requestWillBeSent", params: { - requestId: "bad-coordinates", + requestId: "coordinate-request", timestamp: 1, type: "Script", request: { url: `${origin}/app.js`, method: "GET" }, @@ -71,6 +70,18 @@ it("reports malformed source coordinates as unknown through MCP", async () => { name: "inspect_web_page", arguments: args, }); + const tools = await client.callTool({ + name: "discover_webmcp_tools", + arguments: args, + }); + return { client, page, tools }; +}; + +it("reports malformed source coordinates as unknown through MCP", async () => { + const { page, tools } = await inspectSourceCoordinates({ + lineNumber: -3, + columnNumber: 7.5, + }); expect(page.isError).not.toBe(true); expect(page.structuredContent).toMatchObject({ normalized_result: { @@ -80,7 +91,7 @@ it("reports malformed source coordinates as unknown through MCP", async () => { network: { requests: expect.arrayContaining([ expect.objectContaining({ - request_id: "bad-coordinates", + request_id: "coordinate-request", initiator: expect.objectContaining({ line: null, column: null }), }), ]), @@ -99,10 +110,6 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }, }); - const tools = await client.callTool({ - name: "discover_webmcp_tools", - arguments: args, - }); expect(tools.isError).not.toBe(true); expect(tools.structuredContent).toMatchObject({ normalized_result: { @@ -120,3 +127,69 @@ it("reports malformed source coordinates as unknown through MCP", async () => { }, }); }, 20_000); + +it("keeps a negative column Chrome reports in passive source locations", async () => { + const { client, page, tools } = await inspectSourceCoordinates({ + lineNumber: 21, + columnNumber: -40, + }); + const advertised = (await client.listTools()).tools; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + for (const [name, response] of [ + ["inspect_web_page", page], + ["discover_webmcp_tools", tools], + ] as const) { + expect(response.isError).not.toBe(true); + const outputSchema: Record | undefined = advertised.find( + (tool) => tool.name === name, + )?.outputSchema; + if (outputSchema === undefined) + throw new Error(`Missing advertised output schema: ${name}`); + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + } + const location = { line: 21, column: -40 }; + expect(page.structuredContent).toMatchObject({ + normalized_result: { + console: { events: [expect.objectContaining(location)] }, + network: { + requests: expect.arrayContaining([ + expect.objectContaining({ + request_id: "coordinate-request", + initiator: expect.objectContaining(location), + }), + ]), + }, + }, + }); + expect(tools.structuredContent).toMatchObject({ + normalized_result: { + tools: { + items: [ + expect.objectContaining({ + registration_source: expect.objectContaining(location), + }), + ], + }, + }, + }); + for (const [response, section] of [ + [page, "console_events"], + [page, "network_initiators"], + [tools, "webmcp_tools"], + ] as const) + expect(response.structuredContent).toMatchObject({ + normalized_result: { + completeness: { + excluded: expect.not.arrayContaining([ + expect.objectContaining({ + section, + reason: "invalid_protocol_value", + }), + ]), + }, + }, + }); +}, 20_000); diff --git a/tests/boundary/mcp/compactInputSchemaMcp.test.ts b/tests/boundary/mcp/compactInputSchemaMcp.test.ts index 8b916cca73..87a3d53554 100644 --- a/tests/boundary/mcp/compactInputSchemaMcp.test.ts +++ b/tests/boundary/mcp/compactInputSchemaMcp.test.ts @@ -644,3 +644,30 @@ it.each(["full", "compact"] as const)( expect(calls).toBe(1); }, ); + +it("keeps root detail guidance so callers can choose bounded summaries", async () => { + const full = fullServer(); + const compact = compactServer(); + registerCatalog(full); + registerCatalog(compact); + const [fullTools, compactTools] = await Promise.all([ + listToolsThroughTransport(full), + listToolsThroughTransport(compact), + ]); + const detailGuidance = (tools: readonly AdvertisedTool[]) => + Object.fromEntries( + tools.flatMap(({ name, inputSchema }) => { + const detail = ( + inputSchema.properties as Record> + )?.detail; + return typeof detail?.description === "string" + ? [[name, detail.description]] + : []; + }), + ); + const expected = detailGuidance(fullTools); + expect(Object.keys(expected)).toEqual( + expect.arrayContaining(["inspect_artifact", "inspect_macho"]), + ); + expect(detailGuidance(compactTools)).toEqual(expected); +}); diff --git a/tests/boundary/mcp/deferredRuntime.test.ts b/tests/boundary/mcp/deferredRuntime.test.ts index f52e3f166b..3a5e7ba962 100644 --- a/tests/boundary/mcp/deferredRuntime.test.ts +++ b/tests/boundary/mcp/deferredRuntime.test.ts @@ -670,23 +670,41 @@ const copyCompiledPackage = async (prefix: string): Promise => { return directory; }; +/** + * Modules `record-mcp-catalog.mjs` loads before it owns cleanup. The fixture + * replaces every one of these; an import outside the list fails closed. + */ +const RECORDER_RUNTIME_MODULES = [ + "server/createServer.js", + "composition/binary.js", + "config/parseConfig.js", + "logger.js", + "contracts/promptContracts.js", + "contracts/toolContracts.js", + "domain/evidenceInputContract.js", +] as const; + // Recorder cleanup tests replace every runtime import through their loader // hook. Keep only resolvable sentinels so an unmodeled import fails closed. const createRecorderCleanupFixture = async (): Promise => { const directory = await createTestTempDirectory("rea-catalog-cleanup-"); await mkdir(join(directory, "scripts"), { recursive: true }); - await cp( - resolve("scripts/record-mcp-catalog.mjs"), - join(directory, "scripts/record-mcp-catalog.mjs"), - ); + const scriptPath = resolve("scripts/record-mcp-catalog.mjs"); + const imported = [ + ...((await readFile(scriptPath, "utf8")).matchAll(/read\("([^"]+)"\)/g) ?? + []), + ].map((match) => match[1]); + if ( + imported.length !== RECORDER_RUNTIME_MODULES.length || + imported.some((name, index) => name !== RECORDER_RUNTIME_MODULES[index]) + ) { + throw new Error( + `record-mcp-catalog.mjs runtime imports ${JSON.stringify(imported)} do not match the cleanup fixture ${JSON.stringify(RECORDER_RUNTIME_MODULES)}`, + ); + } + await cp(scriptPath, join(directory, "scripts/record-mcp-catalog.mjs")); await cp(resolve("package.json"), join(directory, "package.json")); - for (const path of [ - "server/createServer.js", - "composition/binary.js", - "config/parseConfig.js", - "logger.js", - "contracts/promptContracts.js", - ]) { + for (const path of RECORDER_RUNTIME_MODULES) { const file = join(directory, "dist", path); await mkdir(dirname(file), { recursive: true }); await writeFile( @@ -771,6 +789,11 @@ it.each([ "/dist/logger.js": "export const silentLogger = {};", "/dist/contracts/promptContracts.js": "export const PROMPT_CONTRACTS = [];", + // The recorder derives Evidence-input names from these contracts. This + // fault injection has no tools, so the derived list stays empty. + "/dist/contracts/toolContracts.js": "export const TOOL_CONTRACTS = [];", + "/dist/domain/evidenceInputContract.js": + "export const getEvidenceInputContract = () => ({ hasEvidenceReferenceAlternative: false });", }; const clientModule = `data:text/javascript,${encodeURIComponent(` export class Client { @@ -803,6 +826,9 @@ registerHooks({ }, }); `)}`; + expect( + Object.keys(replacements).map((path) => path.slice("/dist/".length)), + ).toEqual([...RECORDER_RUNTIME_MODULES]); const child = spawnSync( process.execPath, [`--import=${hook}`, join(directory, "scripts/record-mcp-catalog.mjs")], diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index 68313a7be4..d1eacd2063 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -440,14 +440,14 @@ describe("enhanced MCP tools", () => { ]), }, }); + // A fixed adapter has no session to retain source Evidence for the trace. expect(results[13]).toMatchObject({ - target_sha256: "a".repeat(64), - decompilations: 1, - unknowns: [ - expect.objectContaining({ - reason: "Provider has no native def-use model", - }), - ], + error: { + code: "capability_unavailable", + details: { + operation: "trace_native_values", + }, + }, }); expect(results[14]).toMatchObject({ counts: { selected: 6, written: 6, failed: 0, excluded: 0 }, diff --git a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts index 0b8c10c1b2..5f2af27fab 100644 --- a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts +++ b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts @@ -101,6 +101,9 @@ it("exact-name xrefs preserves ambiguity independently of inventory order and ac }); expect(parseMcpToolError(rejected).error).toMatchObject({ code: "invalid_request", + remediation: { + action: expect.stringContaining("Select an exact address"), + }, details: { issues: [ expect.objectContaining({ @@ -110,6 +113,7 @@ it("exact-name xrefs preserves ambiguity independently of inventory order and ac ], }, }); + expect(parseMcpToolError(rejected).error.recovery).toBeUndefined(); } expect(queried).toEqual([]); rows = [ diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index 1fb25aa167..41c1ba84d4 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -10,7 +10,6 @@ import { import { parseEvidence } from "../../../src/domain/evidence.js"; import { batchDecompileResultSchema } from "../../../src/domain/native/batchDecompile.js"; import { ENHANCED_TOOL_CONTRACTS } from "../../../src/contracts/enhancedToolContracts.js"; -import { NULL_PSEUDOCODE_GUIDANCE } from "../../../src/application/EnhancedTools.js"; import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; import { err, ok as resultOk } from "../../../src/domain/result.js"; @@ -664,33 +663,20 @@ describe("enhanced MCP batch answers without pseudocode", () => { if (contract === undefined) throw new Error("Missing batch_decompile contract"); const evidence = contract.outputSchema.parse(result.structuredContent); - const noPseudocode = ( - guidance: (typeof NULL_PSEUDOCODE_GUIDANCE)[keyof typeof NULL_PSEUDOCODE_GUIDANCE], - ) => ({ - code: "capability_unavailable", - retryable: false, - message: guidance.userMessage, - remediation: { action: guidance.remediationAction }, - details: { - provider_id: "fixture", - operation: "procedure_pseudo_code", - reason: "provider returned null pseudocode", - }, - }); expect(evidence.normalized_result).toMatchObject({ items: [ { address: "0x1", status: "ok", pseudocode: answers["0x1"]?.result }, { address: "0x2", - status: "error", - error: noPseudocode(NULL_PSEUDOCODE_GUIDANCE.reported), + status: "unavailable", + reason: "no_pseudocode", limitations: answers["0x2"]?.limitations, }, - // Without caveats, the guidance does not point at an absent field. + // An unclassified absence has no invented reason or retry policy. { address: "0x3", - status: "error", - error: noPseudocode(NULL_PSEUDOCODE_GUIDANCE.none), + status: "unavailable", + reason: "no_pseudocode", }, { address: "0x4", diff --git a/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts new file mode 100644 index 0000000000..2981381ca3 --- /dev/null +++ b/tests/boundary/mcp/evidenceLinkInspectionMcp.test.ts @@ -0,0 +1,398 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterAll, expect, it } from "vitest"; +import { z } from "zod"; +import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; +import { MCP_TRANSPORT_EVIDENCE_LINKS } from "../../../src/domain/analysisErrorPresentation.js"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js"; +import { + EVIDENCE_LINK_INSPECTION_LIMIT, + EVIDENCE_LINK_INSPECTION_LIMITATIONS, + evidenceBundleSummarySchema, + evidenceLinkInspectionSchema, +} from "../../../src/domain/evidenceRecordSummary.js"; +import { createToolResultDelivery } from "../../../src/server/toolResult.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + +const GHIDRA = { id: "ghidra", name: "Ghidra", version: "12.1.4" }; +const TARGET = { + path: "/fixtures/a.macho", + format: "mach-o" as const, + sha256: "a".repeat(64), +}; +const record = (operation: string, evidenceLinks: readonly string[] = []) => + createEvidence(TARGET, GHIDRA, { + operation, + parameters: {}, + result: { operation }, + evidenceLinks, + }); + +// top -> middle -> absent source; top -> leaf; unrelated stands alone. +const source = record("xrefs"); +const leaf = record("procedure_address"); +const middle = record("trace_feature", [source.evidence_id]); +const top = record("analyze_function_summary", [ + middle.evidence_id, + leaf.evidence_id, +]); +const unrelated = record("list_names"); +const PARTIAL = [top, middle, leaf, unrelated]; + +const connect = async ( + options: { + readonly profile?: "full" | "compact"; + readonly records?: readonly ReturnType[]; + readonly maximumResponseBytes?: number; + } = {}, +) => { + const session = createTestBinarySession(() => { + throw new Error("Link inspection must not open a provider"); + }); + const { client } = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "evidence-link-inspection", version: "1" }, + schemaValidator: schemaValidation, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: options.profile ?? "full", + REA_MCP_OUTPUT_SCHEMA_PROFILE: options.profile ?? "full", + }, + ...(options.maximumResponseBytes === undefined + ? {} + : { delivery: createToolResultDelivery(options.maximumResponseBytes) }), + }, + }); + for (const retained of options.records ?? PARTIAL) + expect(session.recordEvidence(retained).ok).toBe(true); + const reply = (args: Record) => + client.callTool({ name: "get_evidence_bundle", arguments: args }); + // Every delivered result must satisfy what this profile actually advertises. + const { tools } = await client.listTools(); + const advertised = tools.find(({ name }) => name === "get_evidence_bundle"); + if (advertised?.outputSchema === undefined) + throw new Error("get_evidence_bundle advertises no output schema"); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + const validation = schemaValidation.assertions; + expect(validation.validateSchema(inputSchema)).toBe(true); + expect(validation.validateSchema(outputSchema)).toBe(true); + const resultOf = async (args: Record) => { + expect( + validation.validate(inputSchema, args), + JSON.stringify(validation.errors), + ).toBe(true); + const result = await reply(args); + expect(result.isError, JSON.stringify(result)).not.toBe(true); + expect( + validation.validate(outputSchema, result.structuredContent), + JSON.stringify(validation.errors), + ).toBe(true); + return z.object({ result: z.unknown() }).parse(result.structuredContent) + .result; + }; + const links = async (args: Record = {}) => + evidenceLinkInspectionSchema.parse( + await resultOf({ detail: "links", ...args }), + ); + const summary = async (args: Record = {}) => + evidenceBundleSummarySchema.parse( + await resultOf({ detail: "summary", ...args }), + ); + return { client, session, reply, links, summary }; +}; + +const exactly = (record_: { readonly evidence_id: string }) => ({ + filters: { evidence_id: record_.evidence_id }, +}); + +it.each(["full", "compact"] as const)( + "reads direct membership against the whole session, not the selection (%s)", + async (profile) => { + const { links, summary } = await connect({ profile }); + + // A retained-but-unselected source is retained, and direct makes no closure claim. + const selected = await links(exactly(top)); + expect(selected).toEqual({ + kind: "evidence-link-inspection", + filters: exactly(top).filters, + traversal: "direct", + link_status: "missing", + total_retained_records: 4, + matching_records: 1, + examined_records: 1, + links: { total: 2, retained: 2, missing: 0 }, + retained_closure: null, + offset: 0, + limit: EVIDENCE_LINK_INSPECTION_LIMIT, + matching_links: 0, + next_offset: null, + rows: [], + limitations: [...EVIDENCE_LINK_INSPECTION_LIMITATIONS], + }); + + // The record that names the absent source reports it without any payload. + const missing = await links(exactly(middle)); + expect(missing.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(missing.rows).toEqual([ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ]); + expect(JSON.stringify(missing)).not.toContain("trace_feature"); + + // The summary counts direct links only for its matching records. + expect(await summary()).toMatchObject({ + matching_records: 4, + direct_links: 3, + missing_direct_links: 1, + }); + expect(await summary(exactly(top))).toMatchObject({ + matching_records: 1, + direct_links: 2, + missing_direct_links: 0, + }); + }, +); + +it("follows retained links only when asked and leaves the rest unknown", async () => { + const { links } = await connect(); + const walk = { traversal: "retained-closure" }; + + // Top looks closed directly, but its closure reaches the absent source. + const closure = await links({ ...exactly(top), ...walk }); + expect(closure).toMatchObject({ + traversal: "retained-closure", + matching_records: 1, + examined_records: 3, + links: { total: 3, retained: 2, missing: 1 }, + retained_closure: "incomplete", + rows: [ + { + evidence_id: middle.evidence_id, + linked_evidence_id: source.evidence_id, + status: "missing", + }, + ], + }); + + // A branch without an absent link is complete, including a record with no links. + expect(await links({ ...exactly(leaf), ...walk })).toMatchObject({ + examined_records: 1, + links: { total: 0, retained: 0, missing: 0 }, + retained_closure: "complete", + }); + + // Selecting nothing supports no claim about a closure. + expect( + await links({ filters: { operation: "absent_operation" }, ...walk }), + ).toMatchObject({ + matching_records: 0, + examined_records: 0, + links: { total: 0 }, + retained_closure: null, + rows: [], + }); +}); + +it("counts a repeated link once in every delivery", async () => { + const repeated = record("trace_feature", [ + source.evidence_id, + source.evidence_id, + ]); + const { links, summary } = await connect({ records: [repeated] }); + expect(await summary()).toMatchObject({ + direct_links: 1, + missing_direct_links: 1, + }); + const inspected = await links(); + expect(inspected.links).toEqual({ total: 1, retained: 0, missing: 1 }); + expect(inspected.rows).toHaveLength(1); +}); + +it("pages selected link rows in a stable order without skips or duplicates", async () => { + const { links } = await connect(); + const everything = await links({ link_status: "all" }); + expect(everything).toMatchObject({ + link_status: "all", + links: { total: 3, retained: 2, missing: 1 }, + matching_links: 3, + next_offset: null, + }); + const key = ({ + evidence_id: id, + linked_evidence_id: link, + }: (typeof everything.rows)[number]) => `${id}>${link}`; + const order = everything.rows.map(key); + expect(order).toEqual( + [...order].sort((left, right) => (left < right ? -1 : 1)), + ); + + const paged: string[] = []; + let offset: number | null = 0; + while (offset !== null) { + const page: z.output = await links({ + link_status: "all", + offset, + limit: 1, + }); + expect(page.rows).toHaveLength(1); + // Counts describe every inspected link on every page. + expect(page.links.total).toBe(3); + paged.push(...page.rows.map(key)); + offset = page.next_offset; + } + expect(paged).toEqual(order); + + const retainedOnly = await links({ link_status: "retained" }); + expect(retainedOnly.matching_links).toBe(2); + expect(retainedOnly.rows.every(({ status }) => status === "retained")).toBe( + true, + ); + expect(await links({ link_status: "all", offset: 9 })).toMatchObject({ + matching_links: 3, + next_offset: null, + rows: [], + }); +}); + +it("reports the source a composed workflow refuses, and clears it on import", async () => { + const dossier = FUNCTION_COMPARISON_EXAMPLE.left; + if (dossier.subject === null) throw new Error("Expected a subject"); + const health = record("get_project_health"); + const linked = createEvidence( + { + path: dossier.subject.local_path, + sha256: dossier.subject.digest.sha256, + format: dossier.subject.format, + }, + dossier.provider, + { + operation: dossier.operation, + parameters: dossier.parameters, + result: dossier.normalized_result, + confidence: dossier.confidence, + authority: dossier.authority, + evidenceLinks: [health.evidence_id], + }, + ); + const { client, links, summary } = await connect({ records: [linked] }); + const walk = { traversal: "retained-closure" }; + expect(await links(walk)).toMatchObject({ + retained_closure: "incomplete", + rows: [ + { + evidence_id: linked.evidence_id, + linked_evidence_id: health.evidence_id, + }, + ], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 1 }); + + const procedure = z + .looseObject({ procedure: z.looseObject({ address: z.string() }) }) + .parse(dossier.normalized_result).procedure.address; + const callPath = () => + client.callTool({ + name: "build_call_path", + arguments: { + start: { address: procedure }, + goal: { address: procedure }, + functions: [ + { kind: "retained-evidence", evidence_id: linked.evidence_id }, + ], + }, + }); + // The inspection predicts the same absent ID the workflow refuses. + expect(JSON.stringify(parseMcpToolError(await callPath()))).toContain( + health.evidence_id, + ); + + const root = await createTestTempDirectory("rea-evidence-links-"); + const bundlePath = join(root, "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([health]))); + const imported = await client.callTool({ + name: "import_evidence_bundle", + arguments: { path: bundlePath }, + }); + expect(imported.isError, JSON.stringify(imported)).not.toBe(true); + + // Membership changes, the original record does not, and the workflow proceeds. + expect(await links(walk)).toMatchObject({ + examined_records: 2, + links: { total: 1, retained: 1, missing: 0 }, + retained_closure: "complete", + rows: [], + }); + expect(await summary()).toMatchObject({ missing_direct_links: 0 }); + const proceeded = await callPath(); + expect(proceeded.isError, JSON.stringify(proceeded)).not.toBe(true); + expect(parseEvidence(proceeded.structuredContent).evidence_links).toContain( + linked.evidence_id, + ); +}); + +it("refuses link selections outside link delivery and names a smaller page", async () => { + const { reply } = await connect(); + for (const [detail, field, value, expected] of [ + ["summary", "traversal", "direct", "detail: links"], + ["records", "link_status", "all", "detail: links"], + ["complete", "traversal", "retained-closure", "detail: links"], + ["summary", "offset", 1, "detail: records or detail: links"], + ["summary", "limit", 1, "detail: records or detail: links"], + ] as const) + expect( + parseMcpToolError(await reply({ detail, [field]: value })).error, + ).toMatchObject({ + code: "invalid_request", + details: { + issues: [expect.objectContaining({ path: [field], expected })], + }, + }); + expect( + parseMcpToolError( + await reply({ detail: "complete", filters: exactly(top).filters }), + ).error, + ).toMatchObject({ code: "invalid_request" }); + + // Many links on one record overflow a small response budget with a named step. + const many = Array.from( + { length: 64_000 }, + (_, index) => `ev_${index.toString(16).padStart(64, "0")}`, + ); + const crowded = await connect({ + records: [record("trace_feature", many)], + maximumResponseBytes: 10 * 1024 * 1024, + }); + const large = await crowded.reply({ detail: "links", limit: 64_000 }); + expect(parseMcpToolError(large).error).toMatchObject({ + code: "resource_constraint", + remediation: { + action: expect.stringContaining(MCP_TRANSPORT_EVIDENCE_LINKS), + }, + }); + expect( + evidenceLinkInspectionSchema.parse( + z + .object({ result: z.unknown() }) + .parse( + (await crowded.reply({ detail: "links", limit: 2 })) + .structuredContent, + ).result, + ), + ).toMatchObject({ + links: { total: 64_000, missing: 64_000 }, + next_offset: 2, + }); +}); diff --git a/tests/boundary/mcp/ghidraRegexDiagnosticsMcp.test.ts b/tests/boundary/mcp/ghidraRegexDiagnosticsMcp.test.ts new file mode 100644 index 0000000000..54276721f8 --- /dev/null +++ b/tests/boundary/mcp/ghidraRegexDiagnosticsMcp.test.ts @@ -0,0 +1,63 @@ +import { expect, it } from "vitest"; +import { err, ok } from "../../../src/domain/result.js"; +import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { connectGhidraMcp } from "./ghidraMcpHarness.js"; + +it.each(["compact", "full"] as const)( + "identifies the invalid Ghidra regex field and permits correction (%s)", + async (profile) => { + const message = + "Invalid regex pattern [: Unclosed character class at index 0"; + const harness = await connectGhidraMcp( + "ghidra-regex-diagnostic", + (operation, input) => + Promise.resolve( + input.pattern === "[" + ? err( + new GhidraSessionError( + "remote", + message, + {}, + { remoteCode: "invalid_regex_pattern" }, + ), + ) + : ok([]), + ), + { environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } }, + ); + try { + for (const operation of [ + "search_strings", + "search_procedures", + ] as const) { + const rejected = await harness.mcp.callTool({ + name: operation, + arguments: { pattern: "[", mode: "regex" }, + }); + const error = parseMcpToolError(rejected).error; + expect(error).toMatchObject({ + code: "invalid_request", + message, + remediation: { + action: expect.stringMatching(/pattern.*mode literal/u), + }, + details: { + issues: [{ path: ["pattern"], reason: "invalid_format", message }], + }, + }); + expect(error.recovery).toMatchObject({ + kind: "inspect_input_contract", + operation, + }); + const corrected = await harness.mcp.callTool({ + name: operation, + arguments: { pattern: "valid", mode: "regex" }, + }); + expect(corrected.isError).not.toBe(true); + } + } finally { + await harness.close(); + } + }, +); diff --git a/tests/boundary/mcp/ghidraRoutingMcp.test.ts b/tests/boundary/mcp/ghidraRoutingMcp.test.ts index dcca3a5017..0b0160d133 100644 --- a/tests/boundary/mcp/ghidraRoutingMcp.test.ts +++ b/tests/boundary/mcp/ghidraRoutingMcp.test.ts @@ -124,11 +124,8 @@ describe("Ghidra MCP capability routing", () => { expect.objectContaining({ address: "0x401000", status: "ok" }), expect.objectContaining({ address: bodyless, - status: "error", - error: expect.objectContaining({ - code: "capability_unavailable", - details: expect.objectContaining({ provider_id: "ghidra" }), - }), + status: "unavailable", + reason: "no_pseudocode", limitations: [...new Set(observed.limitations)], }), ], diff --git a/tests/boundary/mcp/goBinaryMcp.test.ts b/tests/boundary/mcp/goBinaryMcp.test.ts index 909071e375..0ccb8c3cd9 100644 --- a/tests/boundary/mcp/goBinaryMcp.test.ts +++ b/tests/boundary/mcp/goBinaryMcp.test.ts @@ -1,7 +1,7 @@ import { goBinarySchema } from "../../../src/domain/go/goBinary.js"; import type { Client } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; -import { writeFile } from "node:fs/promises"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; @@ -184,6 +184,69 @@ for (const expectedErrno of ["ELOOP", "ENAMETOOLONG"] as const) { ); } +goMcpTest.skipIf(process.platform === "win32")( + "suggests a resolved regular file after rejecting a selected symlink", + async ({ go: { client } }) => { + const root = await createTestTempDirectory("rea-go-mcp-symlink-"); + const original = join(root, "original.elf"); + const selected = join(root, "selected"); + await writeFile(original, createGoBinaryFixture().bytes); + await symlink(original, selected); + + const rejected = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(rejected).error; + expect(diagnostic).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + remediation: { action: expect.stringContaining(original) }, + }); + expect(diagnostic).not.toHaveProperty("recovery"); + + const retried = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: original }, + }); + expect(retried.isError).not.toBe(true); + }, +); + +for (const targetKind of ["dangling", "directory"] as const) { + goMcpTest.skipIf(process.platform === "win32")( + `keeps a ${targetKind} symlink rejection without a resolved-file hint`, + async ({ go: { client } }) => { + const root = await createTestTempDirectory("rea-go-mcp-symlink-"); + const target = join(root, "target"); + const selected = join(root, "selected"); + if (targetKind === "directory") await mkdir(target); + await symlink(target, selected); + + const rejected = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(rejected).error; + expect(diagnostic).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining(selected), + }, + ], + }, + }); + expect(diagnostic.remediation.action).not.toContain(target); + }, + ); +} + goMcpTest( "preserves invalid embedded Go text bytes through MCP", async ({ go: { client, session } }) => { diff --git a/tests/boundary/mcp/hexRecordsMcp.test.ts b/tests/boundary/mcp/hexRecordsMcp.test.ts index 5d144967e8..388a9369c4 100644 --- a/tests/boundary/mcp/hexRecordsMcp.test.ts +++ b/tests/boundary/mcp/hexRecordsMcp.test.ts @@ -12,6 +12,56 @@ import { analysisViewResultSchema } from "../../../src/domain/analysisView/analy // The compiled stdio fixture checks advertised schemas against their dialect and // validates actual tool responses, avoiding a second source server module graph. +it.each(["full", "compact"] as const)( + "preserves S28 high address bytes through MCP inventory and extraction (%s)", + async (profile) => { + const root = await createTestTempDirectory("rea-s28-mcp-"); + const path = join(root, "firmware.s28"); + const bytes = Buffer.from("S208FEDCBADEADBEEF2B\nS804FEDCBA67\n"); + await writeFile(path, bytes); + const { call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + let outputRoot: string | undefined; + try { + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const inspected = await call("inspect_artifact", { detail: "summary" }); + expect(inspected.isError, JSON.stringify(inspected)).not.toBe(true); + const summary = z + .object({ + summary: z.object({ root_format_metadata: z.unknown() }), + }) + .parse(parseEvidence(inspected.structuredContent).normalized_result); + expect(summary.summary.root_format_metadata).toMatchObject({ + kind: "hex-records", + encoding: "srec", + variant: "S28", + lowest_address: 0xfe_dcba, + highest_address: 0xfe_dcbd, + start_address: 0xfe_dcba, + start_address_record: "S8", + }); + const extracted = await call("extract_artifact", {}); + expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + outputRoot = result.output_root; + expect(result.containment_verified).toBe(true); + expect( + (await readFile(join(outputRoot, "0x00fedcba-0x00fedcbd.bin"))).equals( + Buffer.from("deadbeef", "hex"), + ), + ).toBe(true); + expect((await readFile(path)).equals(bytes)).toBe(true); + } finally { + if (outputRoot !== undefined) + await rm(outputRoot, { recursive: true, force: true }); + } + }, +); + it.each(["full", "compact"] as const)( "exposes S-record facts and region extraction through MCP (%s)", async (profile) => { diff --git a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts index 0e95800cf4..162a21f606 100644 --- a/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts +++ b/tests/boundary/mcp/investigationEvidenceReferencesMcp.test.ts @@ -108,7 +108,10 @@ it("keeps input capacity limits while composing dossiers that cannot be resent i arguments: { ...selection, functions: [source] }, }); expect(parseMcpToolError(inline)).toMatchObject({ - error: { code: "resource_constraint" }, + error: { + code: "resource_constraint", + remediation: { action: expect.stringContaining("retained-evidence") }, + }, }); const retained = await client.callTool({ name: "build_call_path", diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 64e55f5972..005be263e4 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -1,8 +1,8 @@ import { contractsForToolFamilies } from "../../../src/contracts/toolFamilies.js"; import { Client } from "@modelcontextprotocol/client"; import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { execFile } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { execFile, spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -50,6 +50,42 @@ const expectDoctorDeadline = ( ); }; +const captureProductionCli = async ( + arguments_: readonly string[], + environment: NodeJS.ProcessEnv, +) => + new Promise<{ + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; + }>((finish, reject) => { + const child = spawn( + process.execPath, + [resolve("scripts/rea.mjs"), ...arguments_], + { + cwd: process.cwd(), + env: environment, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + const timeout = setTimeout(() => child.kill("SIGTERM"), 15_000); + child.once("error", reject); + child.once("close", (exitCode) => { + clearTimeout(timeout); + finish({ exitCode, stdout, stderr }); + }); + }); + afterEach(() => vi.useRealTimers()); const assertCliCatalogIdentity = async ( @@ -358,6 +394,70 @@ describe("production MCP doctor family scopes", () => { }); describe("production MCP doctor output", () => { + it("keeps both structured stdout formats parseable when prompts are not advertised", async () => { + const root = await createTestTempDirectory("rea-mcp-doctor-output-"); + const directories = { + home: join(root, "home"), + config: join(root, "config"), + data: join(root, "data"), + cache: join(root, "cache"), + state: join(root, "state"), + temp: join(root, "temp"), + }; + await Promise.all( + Object.values(directories).map((path) => + mkdir(path, { recursive: true }), + ), + ); + const environment = { + ...process.env, + REA_MCP_TOOL_FAMILIES: "browser", + HOME: directories.home, + XDG_CONFIG_HOME: directories.config, + XDG_DATA_HOME: directories.data, + XDG_CACHE_HOME: directories.cache, + XDG_STATE_HOME: directories.state, + TMPDIR: directories.temp, + }; + + const json = await captureProductionCli( + ["mcp", "doctor", "--json"], + environment, + ); + const jsonl = await captureProductionCli( + ["mcp", "doctor", "--format", "jsonl"], + environment, + ); + + expect([json.exitCode, jsonl.exitCode]).toEqual([1, 1]); + const parseable = [json.stdout, jsonl.stdout].map((output) => { + try { + JSON.parse(output); + return true; + } catch { + return false; + } + }); + expect(parseable).toEqual([true, true]); + expect(JSON.parse(json.stdout)).toMatchObject({ + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }); + expect(JSON.parse(jsonl.stdout)).toMatchObject({ + $rea: "jsonl/v1", + type: "error", + ok: false, + result: { + healthy: false, + inventory: { + prompts: { expected: 0, observed: 0, missing: [], unexpected: [] }, + }, + }, + }); + }, 35_000); + it.each([false, true])( "frames unhealthy JSONL diagnostics and retains the selected envelope (%s)", async (full) => { diff --git a/tests/boundary/mcp/mcpSchemaValidation.test.ts b/tests/boundary/mcp/mcpSchemaValidation.test.ts index 2ce015a4f9..e82a633d77 100644 --- a/tests/boundary/mcp/mcpSchemaValidation.test.ts +++ b/tests/boundary/mcp/mcpSchemaValidation.test.ts @@ -7,7 +7,8 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://fixture.test/result", - type: "string" as "string" | "integer", + $defs: { value: { type: "string" as "string" | "integer" } }, + $ref: "#/$defs/value", }; const first = validation.getValidator(schema); expect(validation.getValidator(structuredClone(schema))).toBe(first); @@ -16,7 +17,7 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { expect(validation.assertions.validate(schema, "recorded")).toBe(true); expect(validation.assertions.validate(schema, 42)).toBe(false); - schema.type = "integer"; + schema.$defs.value.type = "integer"; const changed = validation.getValidator(schema); expect(changed).not.toBe(first); expect(changed(42).valid).toBe(true); @@ -31,13 +32,17 @@ it("reuses equal schemas and recompiles changed schemas sharing an id", () => { } }); -it("preserves SDK format validation and declared dialect dispatch", () => { +it.each([ + "https://json-schema.org/draft/2020-12/schema", + "https://json-schema.org/draft/2019-09/schema", + "http://json-schema.org/draft-07/schema#", +])("preserves SDK format validation and references for %s", (dialect) => { const validation = createMcpSchemaValidation(); try { const schema = { - $schema: "http://json-schema.org/draft-07/schema#", - type: "string", - format: "email", + $schema: dialect, + $defs: { address: { type: "string", format: "email" } }, + $ref: "#/$defs/address", } as const; const validator = validation.getValidator(schema); expect(validator("analyst@example.test").valid).toBe(true); diff --git a/tests/boundary/mcp/nativeFeatureTraceViews.test.ts b/tests/boundary/mcp/nativeFeatureTraceViews.test.ts new file mode 100644 index 0000000000..07885b3533 --- /dev/null +++ b/tests/boundary/mcp/nativeFeatureTraceViews.test.ts @@ -0,0 +1,167 @@ +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js"; +import { analysisViewResultSchema } from "../../../src/domain/analysisView/analysisView.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const traceFixture = () => ({ + query: "goal", + search_mode: "literal" as const, + matches: [{ type: "procedure" as const, address: "0x1000", value: "goal" }], + references: ["0x1010", "0x1020", "0x1010"].map((source_address) => ({ + target_address: "0x1000", + source_address, + containing_procedure: { + query_address: source_address, + status: "unavailable" as const, + provider_id: "kuna", + reason: "unsupported", + }, + })), + truncated: true, + residual_unknowns: ["0x1010 owner unknown", "0x1020 owner unknown"], +}); + +it.each(["full", "compact"] as const)( + "pages retained native trace rows and preserves source coverage across %s offline handoff", + async (profile) => { + const result = traceFixture(); + const parent = createEvidence( + undefined, + { id: "rea-workflow", name: "REA workflow", version: "1" }, + { + operation: "trace_feature", + parameters: { query: "goal", case_sensitive: false }, + result, + confidence: "derived", + limitations: ["fixture provider coverage"], + }, + ); + const setup = async () => { + const session = createTestBinarySession(() => { + throw new Error("View must not open a provider"); + }); + const harness = await createApplicationMcpHarness({ + session, + clientIdentity: { name: "native-feature-view", version: "1" }, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }, + }, + }); + return { ...harness, session }; + }; + const first = await setup(); + const call = async ( + client: typeof first.client, + name: string, + args: Record, + ) => { + const reply = await client.callTool({ name, arguments: args }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + return reply.structuredContent; + }; + const source = { + kind: "retained-evidence", + evidence_id: parent.evidence_id, + }; + const view = async ( + client: typeof first.client, + collection: "matches" | "references" | "unknowns", + offset: number, + limit: number, + ) => { + const response = parseEvidence( + await call(client, "inspect_analysis_view", { + source, + view: { kind: "native-feature-trace", collection, offset, limit }, + }), + ); + return analysisViewResultSchema.parse(response.normalized_result); + }; + const root = await createTestTempDirectory( + "rea-native-feature-trace-view-", + ); + const path = join(root, "evidence.json"); + try { + expect(first.session.recordEvidence(parent).ok).toBe(true); + const summary = analysisViewResultSchema.parse( + parseEvidence( + await call(first.client, "inspect_analysis_view", { + source, + view: { kind: "summary" }, + }), + ).normalized_result, + ); + expect(summary).toMatchObject({ + parent_evidence_id: parent.evidence_id, + summary: { matches: 1, references: 3, unknowns: 2, truncated: true }, + }); + const firstPage = await view(first.client, "references", 0, 2); + const lastPage = await view(first.client, "references", 2, 2); + expect( + analysisViewResultSchema.safeParse({ + ...firstPage, + items: { collection: "unknowns", rows: [] }, + }).success, + ).toBe(false); + expect(firstPage).toMatchObject({ + coverage: { total: 3, next_offset: 2, exhausted: false }, + items: { + rows: [ + { source_index: 0, value: result.references[0] }, + { source_index: 1, value: result.references[1] }, + ], + }, + }); + expect(lastPage).toMatchObject({ + coverage: { total: 3, next_offset: null, exhausted: true }, + items: { rows: [{ source_index: 2, value: result.references[2] }] }, + }); + const filtered = analysisViewResultSchema.parse( + parseEvidence( + await call(first.client, "inspect_analysis_view", { + source, + view: { + kind: "native-feature-trace", + collection: "references", + source_address: "0x1010", + offset: 1, + limit: 1, + }, + }), + ).normalized_result, + ); + expect(filtered).toMatchObject({ + coverage: { total: 2, next_offset: null, exhausted: true }, + items: { rows: [{ source_index: 2, value: result.references[2] }] }, + }); + expect(await view(first.client, "unknowns", 1, 5)).toMatchObject({ + items: { + rows: [{ source_index: 1, value: result.residual_unknowns[1] }], + }, + }); + expect(await view(first.client, "matches", 9, 1)).toMatchObject({ + coverage: { status: "empty", total: 1, next_offset: null }, + }); + await call(first.client, "export_evidence_bundle", { path }); + } finally { + await first.close(); + } + const second = await setup(); + try { + await call(second.client, "import_evidence_bundle", { path }); + expect(await view(second.client, "references", 2, 1)).toMatchObject({ + parent_evidence_id: parent.evidence_id, + items: { rows: [{ source_index: 2, value: result.references[2] }] }, + }); + } finally { + await second.close(); + } + }, +); diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index c5a1266865..e4222343d9 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -119,6 +119,33 @@ it("delivers native primitive and graph summaries with exact retained parents an ); expect(rejected.isError).toBe(true); } + const references = await call("xrefs", { address: "0x401000" }); + const source = { + kind: "retained-evidence", + evidence_id: references.evidence_id, + }; + const before = harness.calls.length; + const page = await call("inspect_analysis_view", { + source, + view: { kind: "native-observation", offset: 0, limit: 1 }, + }); + expect(page.normalized_result).toMatchObject({ + parent_evidence_id: references.evidence_id, + parent_operation: "xrefs", + items: { + type: "addresses", + rows: [{ source_index: 0, address: "0x401001" }], + }, + coverage: { total: 1, exhausted: true }, + }); + const caveats = await call("inspect_analysis_view", { + source, + view: { kind: "native-context", facet: "limitations" }, + }); + expect(caveats.normalized_result).toMatchObject({ + parent_operation: "xrefs", + }); + expect(harness.calls).toHaveLength(before); } finally { await harness.close(); } @@ -177,9 +204,12 @@ it("follows native overflow continuations through MCP without repeating provider for (const name of [ ...NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS, "get_call_graph", + "xrefs", ] as const) { const input = - name === "get_call_graph" || name === "resolve_containing_procedure" + name === "get_call_graph" || + name === "resolve_containing_procedure" || + name === "xrefs" ? { address: "0x1000" } : { procedure: "fixture_main" }; const complete = await client.callTool({ name, arguments: input }); diff --git a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts index 941253bf05..d0ce556ea1 100644 --- a/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts +++ b/tests/boundary/mcp/nativeUiPartialCallRoutesMcp.test.ts @@ -14,6 +14,11 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { inspectNativeDispatchMetadata } from "../../../src/domain/native/objcSwiftMetadata.js"; import { nativeInvestigationTraceSchema } from "../../../src/domain/native/nativeInvestigationGraph.js"; +import { + buildInterfaceBuilderAnalysis, + interfaceBuilderLimitsSchema, +} from "../../../src/domain/apple/interfaceBuilderGraph.js"; +import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { err, ok, type Result } from "../../../src/domain/result.js"; import { createServer } from "../../../src/server/createServer.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; @@ -111,6 +116,12 @@ const noClassifiedReferences = { }; type Plan = { + readonly ui?: ReturnType; + readonly implementations?: ReturnType< + typeof inspectNativeDispatchMetadata + >["objc_dispatch_implementations"]; + readonly action?: string; + readonly maxDepth?: number; readonly references?: unknown; readonly referencesFailure?: AnalysisError; readonly uiFailure?: AnalysisError; @@ -142,6 +153,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { plan.referencesFailure !== undefined ) return err(plan.referencesFailure); + if (operation === "decode_interface_builder" && plan.ui !== undefined) + return ok( + createAnalysisExecution(jsonValueSchema.parse(plan.ui), identity, { + subject, + }), + ); if (operation === "decode_interface_builder") return err( new AnalysisCapabilityUnavailableError( @@ -157,7 +174,12 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { target_sha256: sha256, provider: identity, analysis_profile_digest: null, - result: inspectNativeDispatchMetadata([], 1), + result: { + ...inspectNativeDispatchMetadata([], 1), + ...(plan.implementations === undefined + ? {} + : { objc_dispatch_implementations: plan.implementations }), + }, }, identity, { subject }, @@ -220,10 +242,10 @@ function publicTrace(plan: Plan, maxNodes = 12, maxEdges = 500) { const result = await client.callTool({ name: "trace_native_ui_action", arguments: { - action: "0x1000", + action: plan.action ?? "0x1000", max_nodes: maxNodes, max_edges: maxEdges, - max_depth: 1, + max_depth: plan.maxDepth ?? 1, }, }); return result; @@ -237,6 +259,93 @@ const traceFrom = (result: { structuredContent?: unknown }) => }) .parse(result.structuredContent).normalized_result; +it.each([ + { label: "thin record", recordOffset: 1944 }, + { label: "selected-slice container record", recordOffset: 6040 }, + { label: "symbol-only record", recordOffset: null }, +])( + "keeps handler offsets distinct from $label Evidence through public MCP", + async ({ recordOffset }) => { + // Explicit synthetic decoded facts; no AppKit, Ghidra or Kuna runtime claim. + const ui = buildInterfaceBuilderAnalysis({ + targetSha256: sha256, + toolVersion: "synthetic", + documents: [ + { + relativePath: "Main.storyboardc/scene.nib/objects.nib", + archiveSha256: "b".repeat(64), + documentKind: "storyboard_scene", + raw: { + "com.apple.ibtool.document.objects": { + receiver: { customClass: "AuditController" }, + button: { class: "UIButton" }, + }, + "com.apple.ibtool.document.connections": { + button: [ + { + type: "action", + label: "runAction:", + destinationId: "receiver", + }, + ], + }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const recordLocation = { + address: recordOffset === null ? "0x10000077c" : "0x100000798", + file_offset: recordOffset, + }; + const recordEvidence = [ + { + kind: + recordOffset === null + ? ("symbol" as const) + : ("binary_metadata" as const), + description: "Explicit synthetic method entry", + location: recordLocation, + artifact_path: subject.path, + artifact_sha256: sha256, + }, + ]; + const response = await publicTrace({ + ui, + action: "runAction:", + maxDepth: 4, + references: completeReferences([]), + callees: [], + implementations: [ + { + class_name: "AuditController", + selector: "runAction:", + method_type: "instance", + implementation_address: "0x10000077c", + location: recordLocation, + decode: { status: "decoded", reason: null }, + evidence: recordEvidence, + }, + ], + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const trace = traceFrom(response); + const handler = trace.nodes.find( + ({ id }) => id === "native:function:0x10000077c", + ); + expect(handler?.location).toEqual({ + address: "0x10000077c", + file_offset: null, + }); + expect(handler?.evidence).toEqual(recordEvidence); + expect( + trace.edges.find(({ relation }) => relation === "objc_dispatch") + ?.evidence, + ).toEqual(expect.arrayContaining(recordEvidence)); + expect(recordEvidence[0]?.location).toEqual(recordLocation); + }, +); + it("keeps typed callsite evidence and distinct fallback-provider routes for mixed reference kinds", async () => { const response = await publicTrace({}); expect(response.isError).not.toBe(true); diff --git a/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts index 6e28b05750..29e1a695ba 100644 --- a/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts +++ b/tests/boundary/mcp/nativeValueTraceCleanupIncomplete.test.ts @@ -159,10 +159,15 @@ describe("trace_native_values after a validated seed", () => { "keeps an ordinary failure from $read as a located unknown and continues", async ({ read, stop, ordinaryReads }) => { const operation = read.split(":")[0] ?? ""; - const { port, reads } = failingDiamond({ - [read]: childFailure(operation, false), - }); - const client = await connect(port); + // A completed trace cites source Evidence, so continuation needs a + // session that can retain it. A fixed adapter still refuses that result. + const { port, reads } = failingDiamond( + { + [read]: childFailure(operation, false), + }, + { sessionSubject: true }, + ); + const { client } = await openSession(port); const response = await client.callTool(trace); diff --git a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts index 35e6332b2e..04ddd5abfb 100644 --- a/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts +++ b/tests/boundary/mcp/nativeValueTraceCompleteness.test.ts @@ -2,9 +2,17 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, expect, it } from "vitest"; import { valueTraceDiamond } from "../../../src/application/native/NativeValueTrace.fixture.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { z } from "zod"; import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js"; import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js"; import { createServer } from "../../../src/server/createServer.js"; +import { ok } from "../../../src/domain/result.js"; +import { + createBinarySessionTargets, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { observed } from "../../fixtures/analysisExecution.js"; import { closeEnhancedToolResources, resources, @@ -15,8 +23,19 @@ afterEach(closeEnhancedToolResources); it.each(["full", "compact"] as const)( "separates page omission from named traversal budgets through the advertised %s MCP contract", async (profile) => { + const [target] = await createBinarySessionTargets(); + const analysis = valueTraceDiamond({ sessionSubject: true }).port; + const session = createTestBinarySession(() => ({ + execute: (operation, parameters, options) => + operation === "health" + ? Promise.resolve(observed(null)) + : analysis.execute(operation, parameters, options), + close: async () => ok(null), + })); + const opened = await session.open(target); + expect(opened.ok).toBe(true); const server = createServer( - { kind: "fixed", analysis: valueTraceDiamond().port }, + { kind: "session", session }, { environment: { REA_MCP_OUTPUT_SCHEMA_PROFILE: profile } }, ); const client = new Client({ name: "value-trace-test", version: "1.0.0" }); @@ -59,6 +78,24 @@ it.each(["full", "compact"] as const)( traversal: { status: "complete" }, truncated: false, }); + const bundleReply = await client.callTool({ + name: "get_evidence_bundle", + arguments: { detail: "complete" }, + }); + expect(bundleReply.isError).not.toBe(true); + const bundle = z + .object({ result: evidenceBundleSchema }) + .parse(bundleReply.structuredContent).result; + for (const source of [ + ...complete.procedures, + ...complete.nodes, + ...complete.edges, + ]) + expect( + bundle.records.some( + (record) => record.evidence_id === source.evidence_id, + ), + ).toBe(true); const pages = []; for (let offset: number | null = 0; offset !== null;) { const page = await trace({ ...request, offset, limit: 4 }); diff --git a/tests/boundary/mcp/nativeValueTraceFailures.test.ts b/tests/boundary/mcp/nativeValueTraceFailures.test.ts index 4d0540411b..c66c48fcb5 100644 --- a/tests/boundary/mcp/nativeValueTraceFailures.test.ts +++ b/tests/boundary/mcp/nativeValueTraceFailures.test.ts @@ -3,10 +3,24 @@ import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { err } from "../../../src/domain/result.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { valueTraceDiamond } from "../../../src/application/native/NativeValueTrace.fixture.js"; import { closeEnhancedToolResources, connect } from "./enhancedToolsHarness.js"; afterEach(closeEnhancedToolResources); +it("refuses a completed trace when the adapter has no source Evidence owner", async () => { + const client = await connect(valueTraceDiamond().port); + const result = await client.callTool({ + name: "trace_native_values", + arguments: { procedure: "0x1000" }, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "capability_unavailable", + message: expect.stringContaining("source Evidence"), + }); +}); + it.each([ { failure: new AnalysisInputError("analyze_function", { diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts index a821fb5f30..bea2282e45 100644 --- a/tests/boundary/mcp/providerSelectionMcp.test.ts +++ b/tests/boundary/mcp/providerSelectionMcp.test.ts @@ -6,11 +6,14 @@ import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; import type { CallToolResult } from "@modelcontextprotocol/server"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { createDeferred } from "../../fixtures/binarySession.js"; +import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; +import type { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { createAnalysisExecution, @@ -28,6 +31,7 @@ import { err, ok } from "../../../src/domain/result.js"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { existingProjectCapabilities } from "../../../src/ghidra/GhidraProviderCapabilities.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { BINARY_SESSION_SUMMARY_OMITTED_FIELDS } from "../../../src/contracts/sessionStatusContract.js"; @@ -549,6 +553,68 @@ describe("binary_session detail over MCP", () => { }); }); +const expectedOpenSelection = (path: string, sha256: string) => ({ + kind: "binary", + path, + sha256, +}); + +const expectQueuedOpenReceipt = async ( + mcp: Client, + session: BinarySession, + first: string, + second: string, + snapshotPath: string, +) => { + const call = (name: string, args: Record) => + mcp.callTool({ name, arguments: args }); + const originalOpen = session.open.bind(session); + const failedSettled = createDeferred(); + const deliverFailure = createDeferred(); + const intercept = vi + .spyOn(session, "open") + .mockImplementation(async (path, options) => { + const result = await originalOpen(path, options); + if (path === second && options?.loadSnapshot !== undefined) { + failedSettled.resolve(); + await deliverFailure.promise; + } + return result; + }); + // Launch after the release gate exists so a failed assertion still joins it. + const failedSwitch = call("open_binary", { + path: second, + provider_id: "beta", + snapshot_path: snapshotPath, + }); + try { + await awaitAcknowledgement( + failedSettled.promise, + failedSwitch, + "failed snapshot open", + ); + const nextReply = await call("open_binary", { + path: second, + provider_id: "beta", + }); + expect(nextReply.isError).not.toBe(true); + const sha = (value: string) => + createHash("sha256").update(value).digest("hex"); + expect((await call("binary_session", {})).structuredContent).toMatchObject({ + result: { path: second, sha256: sha("second fixture") }, + }); + deliverFailure.resolve(); + const failedReply = await failedSwitch; + expect( + parseMcpToolError(failedReply).error.details?.selection_at_failure, + ).toEqual(expectedOpenSelection(first, sha("first fixture"))); + } finally { + deliverFailure.resolve(); + await failedSwitch.catch(() => undefined); + intercept.mockRestore(); + } +}; + it("reports confirmed snapshot binding differences and keeps the active session", async () => { const testDirectory = await createTestTempDirectory( "rea-snapshot-mismatch-mcp-", @@ -612,9 +678,11 @@ it("reports confirmed snapshot binding differences and keeps the active session" provider_id: provider, snapshot_path: snapshotPath, }); - expect(result.isError).toBe(true); const error = parseMcpToolError(result).error; expect(error.code).toBe("evidence_integrity_mismatch"); + expect(error.details?.selection_at_failure).toEqual( + expectedOpenSelection(first, sha("first fixture")), + ); expect(error.remediation.action).toBe( analysisErrorRemediationAction(new AnalysisSnapshotMismatchError("")), ); @@ -666,6 +734,8 @@ it("reports confirmed snapshot binding differences and keeps the active session" true, ); + await expectQueuedOpenReceipt(mcp, session, first, second, snapshotPath); + revision = 1; const raw = join(testDirectory, "raw.bin"); const rawSnapshot = join(testDirectory, "raw-snapshot.json"); @@ -782,6 +852,70 @@ const structured = (result: CallToolResult): Record => ? parseMcpToolError(result) : z.record(z.string(), z.unknown()).parse(result.structuredContent); +it("preserves a selected Ghidra project's read-only guidance when an edit is requested", async () => { + const directory = await createTestTempDirectory("rea-project-refusal-mcp-"); + const target = join(directory, "fixture.hop"); + await writeFile(target, "fixture"); + const starts: string[] = []; + const base = candidate("ghidra", starts); + const projectCapabilities = existingProjectCapabilities("linux"); + const annotation = projectCapabilities.find( + (entry) => entry.operation === "annotate_native_function", + ); + if (annotation === undefined || annotation.available) + throw new Error("Expected read-only annotation capability"); + expect(annotation.userMessage).toBeDefined(); + expect(annotation.remediation).toBeDefined(); + const provider = { + ...base, + capabilities: () => + projectCapabilities.map((entry) => ({ + ...entry, + provider: base.identity(), + })), + }; + const session = composeBinarySession( + new AnalysisProviderRegistry([provider]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "project-refusal", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(mcp, server, { close: () => session.close() }); + await server.connect(serverTransport); + await mcp.connect(clientTransport); + expect( + (await mcp.callTool({ name: "open_binary", arguments: { path: target } })) + .isError, + ).not.toBe(true); + + const refused = await mcp.callTool({ + name: "annotate_native_function", + arguments: { procedure: "fixture", comment: "must not be applied" }, + }); + expect(refused.isError).toBe(true); + expect(parseMcpToolError(refused)).toMatchObject({ + error: { + code: "capability_unavailable", + message: annotation.userMessage, + details: { reason: "Existing Ghidra project sessions are read-only." }, + remediation: { action: annotation.remediation }, + }, + }); + const status = await sessionStatusOf(mcp, { detail: "complete" }); + const availability = status.parsed.tool_availability.groups.find((group) => + group.names.includes("annotate_native_function"), + ); + expect(availability).toMatchObject({ + available: false, + remediation: annotation.remediation, + }); + expect(starts).toEqual([]); +}); + it("retains attributed mixed profile cleanup through registered MCP calls and partial retry", async () => { directory = await createTestTempDirectory("rea-profile-completion-mcp-"); const path = join(directory, "fixture.hop"); diff --git a/tests/boundary/mcp/selectedSnapshotCleanupMcp.test.ts b/tests/boundary/mcp/selectedSnapshotCleanupMcp.test.ts index 54bdf34cbb..afe973c77a 100644 --- a/tests/boundary/mcp/selectedSnapshotCleanupMcp.test.ts +++ b/tests/boundary/mcp/selectedSnapshotCleanupMcp.test.ts @@ -63,11 +63,7 @@ it("retains a selected snapshot descriptor through MCP cleanup discovery and sam let retained: OwnedFileHandle | undefined; let restoreClose = () => {}; try { - const originalEvidence = await saveSelectedSnapshot( - mcp, - first, - snapshotPath, - ); + const original = await saveSelectedSnapshot(mcp, first, snapshotPath); const snapshotBytes = await readFile(snapshotPath); const snapshot = parseAnalysisSnapshot( JSON.parse(snapshotBytes.toString("utf8")), @@ -165,14 +161,15 @@ it("retains a selected snapshot descriptor through MCP cleanup discovery and sam (await mcp.callTool(query)).structuredContent, ); expect(replayEvidence).toEqual({ - ...originalEvidence, + ...original, evidence_id: expect.any(String), + evidence_links: [original.evidence_id], limitations: [ - ...originalEvidence.limitations, + ...original.limitations, "Loaded from a local REA analysis snapshot; this call did not re-run the provider.", ], }); - expect(replayEvidence.evidence_id).not.toBe(originalEvidence.evidence_id); + expect(replayEvidence.evidence_id).not.toBe(original.evidence_id); expect(starts).toBe(priorStarts); expect(calls).toEqual(priorCalls); expect(Buffer.compare(await readFile(snapshotPath), snapshotBytes)).toBe(0); diff --git a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts index 929379e5d4..d0343707a0 100644 --- a/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts +++ b/tests/boundary/mcp/sessionAvailabilityRecovery.test.ts @@ -532,6 +532,42 @@ const DIRECT_OPERATIONS = [ const ADDRESS = { address: "0x1000", document: "fixture" }; const PROCEDURE = { procedure: "main" }; +it("preserves the user-facing recovery for an observed refusal and clears it after success", async () => { + let failing = true; + const respond = { + current: (operation: string): Promise => + failing + ? Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + FIXTURE.id, + operation, + "project export failed", + { + userMessage: + "Project export failed. Resolve the cause and reopen the target.", + }, + ), + ), + ) + : Promise.resolve(observed("fixture-text")), + }; + const { call, availability } = await connectScripted({ + operations: ["address_name"], + respond, + }); + expect(await call("address_name", ADDRESS)).toBe("error"); + expect(await availability(["address_name"])).toEqual({ + address_name: + "Project export failed. Resolve the cause and reopen the target.", + }); + failing = false; + expect(await call("address_name", ADDRESS)).toBe("ok"); + expect(await availability(["address_name"])).toEqual({ + address_name: "available", + }); +}); + it("returns provider-inferred siblings to declared availability after one success", async () => { let broken = false; const respond = { diff --git a/tests/boundary/mcp/sessionTargetRequirements.test.ts b/tests/boundary/mcp/sessionTargetRequirements.test.ts new file mode 100644 index 0000000000..5dc9879b28 --- /dev/null +++ b/tests/boundary/mcp/sessionTargetRequirements.test.ts @@ -0,0 +1,150 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { buildCapabilityInventory } from "../../../src/application/CapabilityInventory.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { zipWith } from "../../fixtures/apkSignature/v1SignatureBlock.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("advertises closed-session address and explicit-path APK tools according to their actual prerequisites", async () => { + const root = await createTestTempDirectory( + "rea-session-target-requirements-", + ); + const path = join(root, "unsigned.zip"); + await writeFile(path, await zipWith([{ name: "sample.txt", data: "hello" }])); + const { call } = await connectLocalToolsMcp(); + const status = async () => { + const reply = await call("binary_session", {}); + expect(reply.isError).not.toBe(true); + const result = toolContract("binary_session").outputSchema.parse( + reply.structuredContent, + ).result; + expect(result).toMatchObject({ open: false, cleanup_pending: false }); + return expandToolAvailability(result.tool_availability); + }; + const availability = ( + tools: Awaited>, + name: string, + ) => { + const entry = tools.find((tool) => tool.name === name); + expect(entry, `missing ${name} in binary_session`).toBeDefined(); + return entry; + }; + + const before = await status(); + expect(availability(before, "get_navigation_context")).toMatchObject({ + available: false, + reason: "target_required", + }); + expect.soft(availability(before, "inspect_address_context")).toMatchObject({ + available: false, + reason: "target_required", + }); + const owningAvailability = buildCapabilityInventory( + { open: false, capabilities: [] }, + { processCaptureEnabled: false }, + ); + expect(availability(before, "inspect_address_context")?.remediation).toBe( + owningAvailability.find((tool) => tool.name === "inspect_address_context") + ?.remediation, + ); + expect.soft(availability(before, "inspect_apk_signature")).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + + const address = await call("inspect_address_context", { address: "0x1000" }); + expect(address.isError).toBe(true); + expect(parseMcpToolError(address)).toMatchObject({ + error: { code: "target_unavailable" }, + }); + const apk = await call("inspect_apk_signature", { path }); + expect(apk.isError, JSON.stringify(apk)).not.toBe(true); + expect(apk.structuredContent).toMatchObject({ + operation: "inspect_analysis_view", + }); + + const after = await status(); + expect(availability(after, "inspect_address_context")).toMatchObject({ + available: false, + reason: "target_required", + }); + expect(availability(after, "inspect_apk_signature")).toMatchObject({ + available: true, + reason: "available", + }); +}); + +it("reports default address resolution unavailable for an artifact while retaining explicit-document results", async () => { + const root = await createTestTempDirectory("rea-address-artifact-status-"); + const path = join(root, "sample.plist"); + await writeFile( + path, + 'CFBundleNameExample', + ); + const { call } = await connectLocalToolsMcp(); + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + for (const detail of ["summary", "complete"] as const) { + const reply = await call("binary_session", { detail }); + expect(reply.isError).not.toBe(true); + const result = toolContract("binary_session").outputSchema.parse( + reply.structuredContent, + ).result; + const entry = expandToolAvailability(result.tool_availability).find( + (tool) => tool.name === "inspect_address_context", + ); + expect(entry).toMatchObject({ + available: false, + reason: "target_unsupported", + remediation: expect.stringContaining("explicit document"), + }); + const navigation = expandToolAvailability(result.tool_availability).find( + (tool) => tool.name === "get_navigation_context", + ); + expect(navigation).toMatchObject({ + available: false, + reason: "target_unsupported", + default_mode_available: false, + modes: [ + { name: "current_selection", available: false }, + { name: "explicit_document", available: false }, + ], + }); + } + for (const input of [{}, { document: "sample.plist" }]) { + const navigation = await call("get_navigation_context", input); + expect(parseMcpToolError(navigation)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { selection_reason: "target_unsupported" }, + }, + }); + } + const implicit = await call("inspect_address_context", { address: "0x1000" }); + expect(parseMcpToolError(implicit)).toMatchObject({ + error: { + code: "capability_unavailable", + details: { selection_reason: "target_unsupported" }, + }, + }); + const explicit = await call("inspect_address_context", { + address: "0x1000", + document: "sample.plist", + }); + expect(explicit.isError, JSON.stringify(explicit)).not.toBe(true); + expect(explicit.structuredContent).toMatchObject({ + result: { + name: { state: "unavailable" }, + procedure: { state: "unavailable" }, + comment: { state: "unavailable" }, + inline_comment: { state: "unavailable" }, + bookmarks: { state: "unavailable" }, + }, + }); +}); diff --git a/tests/boundary/mcp/shutdown.test.ts b/tests/boundary/mcp/shutdown.test.ts index 21222d7bd5..bf01cebbd2 100644 --- a/tests/boundary/mcp/shutdown.test.ts +++ b/tests/boundary/mcp/shutdown.test.ts @@ -6,6 +6,10 @@ import { expect, it } from "vitest"; import { createManagedBinarySession } from "../../../src/composition/binary.js"; import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js"; import { createShutdown } from "../../../src/main/shutdown.js"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { registerProcessShutdown } from "../../../src/main/processShutdown.js"; import type { RuntimeDependencies } from "../../../src/main/types.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; @@ -75,6 +79,79 @@ it("keeps shutdown output stable while logging the rejected cause at debug level ); }); +it("names retained debuggers in stderr and every log level when shutdown ends", async () => { + const logs: string[] = []; + const output: string[] = []; + const requests: Array<() => void> = []; + const retained = new OwnedDebuggerCleanupError( + [new Error("detach unverified")], + "One or more owned GDB processes did not stop cleanly", + [4242], + ); + const dependencies: RuntimeDependencies = { + env: {}, + serve: () => { + throw new Error("unused fixture transport"); + }, + writeStderr: (text) => output.push(text), + setExitCode: () => undefined, + registerShutdown: (request) => { + requests.push(request); + return () => undefined; + }, + }; + createShutdown({ + // Runtime owners wrap this failure before it reaches the shutdown report. + handle: { + close: () => + Promise.reject( + new AggregateError( + [new AggregateError([retained], "REA provider cleanup failed")], + "REA server cleanup failed", + ), + ), + }, + session: createManagedBinarySession(), + dependencies, + serverLogger: pino( + { level: "debug" }, + { write: (line) => logs.push(line) }, + ), + }); + + requests[0]?.(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(output).toEqual([ + `${MCP_SHUTDOWN_FAILED}\n`, + `${retainedDebuggerRemediation([4242])}\n`, + ]); + // Comparing with the formatter alone would still pass if it dropped the process. + expect(output[1]).toMatch(/\b4242\b/u); + const parsed = logs.map((line) => JSON.parse(line)); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 50, + msg: MCP_SHUTDOWN_FAILED, + retained_debugger_pids: [4242], + }), + ); + expect(parsed).toContainEqual( + expect.objectContaining({ + level: 20, + failure_cause: expect.objectContaining({ + errors: [ + expect.objectContaining({ + errors: [ + expect.objectContaining({ retained_debugger_pids: [4242] }), + ], + }), + ], + }), + }), + ); +}); + it("reports a typed binary cleanup failure and retains the client for retry", async () => { const [target] = await createBinarySessionTargets(); const failure = new ProviderCleanupError("fixture", ["owned-runtime"], { diff --git a/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts new file mode 100644 index 0000000000..c1611a1021 --- /dev/null +++ b/tests/boundary/mcp/stableArtifactSymlinkRecoveryMcp.test.ts @@ -0,0 +1,56 @@ +import { symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { createServer } from "../../../src/server/createServer.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../support/mcp/mcpFixture.js"; + +mcpTest.skipIf(process.platform === "win32")( + "offers one resolved-file retry for sibling stable-artifact inspectors", + async ({ mcp }) => { + const root = await createTestTempDirectory("rea-stable-recovery-mcp-"); + const target = join(root, "target.bin"); + const selected = join(root, "selected"); + await writeFile(target, "carrier"); + await symlink(target, selected); + const session = createTestBinarySession(() => { + throw new Error("Target-free inspection must not start a deep provider"); + }); + try { + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + for (const name of [ + "inspect_rust_binary", + "inspect_elf_notes", + "inspect_devicetree", + "inspect_sqlite_database", + ]) { + const response = await client.callTool({ + name, + arguments: { path: selected }, + }); + const diagnostic = parseMcpToolError(response).error; + expect(diagnostic.remediation.action, name).toContain(target); + expect(diagnostic, name).not.toHaveProperty("recovery"); + } + const captureResponse = await client.callTool({ + name: "inspect_web_network_capture", + arguments: { + capture_path: selected, + format: "har", + sensitive_values: ["unrelated marked value"], + }, + }); + const captureError = parseMcpToolError(captureResponse).error; + expect(captureError.remediation.action).toContain(target); + expect(captureError).not.toHaveProperty("recovery"); + } finally { + const closed = await session.close(); + if (!closed.ok) throw closed.error; + } + }, +); diff --git a/tests/boundary/mcp/targetContentFramingMcp.test.ts b/tests/boundary/mcp/targetContentFramingMcp.test.ts index 2b575e5368..044509f6ab 100644 --- a/tests/boundary/mcp/targetContentFramingMcp.test.ts +++ b/tests/boundary/mcp/targetContentFramingMcp.test.ts @@ -1,4 +1,8 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { serverInstructions } from "../../../src/server/serverInstructions.js"; +import { SCOPED_NO_TARGET_RECOVERY } from "../../../src/server/recoveryInstructions.js"; +import { NoBinaryOpenError } from "../../../src/domain/configurationErrors.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "../../../src/server/toolResultEncoding.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { @@ -8,6 +12,8 @@ import type { import { afterEach, describe, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { err, ok } from "../../../src/domain/result.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; @@ -272,3 +278,83 @@ describe.each([ }, ); }); + +it.each([ + { scope: "direct", sessionAvailable: false }, + { scope: "session,direct", sessionAvailable: true }, + { scope: undefined, sessionAvailable: true }, +])( + "guides a missing target using the advertised scope ($scope)", + async ({ scope, sessionAvailable }) => { + const environment = { ...process.env }; + if (scope === undefined) delete environment.REA_MCP_TOOL_FAMILIES; + else environment.REA_MCP_TOOL_FAMILIES = scope; + const client = await connect(environment); + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names.includes("open_binary")).toBe(sessionAvailable); + const { error } = parseMcpToolError( + await client.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(error.code).toBe("target_unavailable"); + if (sessionAvailable) { + expect(error.remediation.action).toBe(new NoBinaryOpenError().message); + } else { + expect(error.message).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + expect(error.remediation.action).toContain("REA_MCP_TOOL_FAMILIES"); + expect(error.remediation.action).toContain("open_binary"); + await expect( + client.callTool({ name: "open_binary", arguments: { path: "/tmp/x" } }), + ).rejects.toThrow(/disabled/u); + } + }, +); + +it("warns that retained evidence does not survive the scoped restart", async () => { + const directory = await createTestTempDirectory("rea-scoped-retained-"); + const path = join(directory, "fixture.exe"); + await writeFile(path, peExportImage({})); + const before = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "direct,native,application", + }); + const observed = await before.callTool({ + name: "inspect_native_exports", + arguments: { path, detail: "summary" }, + }); + expect(observed.isError).not.toBe(true); + const summary = observed.structuredContent as { + normalized_result: { parent_evidence_id: string }; + }; + const view = { + source: { + kind: "retained-evidence", + evidence_id: summary.normalized_result.parent_evidence_id, + }, + view: { kind: "native-exports", collection: "exports" }, + }; + expect( + (await before.callTool({ name: "inspect_analysis_view", arguments: view })) + .isError, + ).not.toBe(true); + const failure = parseMcpToolError( + await before.callTool({ + name: "read_bytes", + arguments: { address: "0x1000", length: 4 }, + }), + ); + expect(failure.error.remediation.action).toBe(SCOPED_NO_TARGET_RECOVERY); + await before.close(); + + const after = await connect({ + ...process.env, + REA_MCP_TOOL_FAMILIES: "session,direct,native,application", + }); + const missing = parseMcpToolError( + await after.callTool({ name: "inspect_analysis_view", arguments: view }), + ); + expect(missing.error.code).toBe("evidence_integrity_mismatch"); +}); diff --git a/tests/boundary/mcp/toolInputCapacityHttp.test.ts b/tests/boundary/mcp/toolInputCapacityHttp.test.ts index 9ac16a46e7..aa3619f865 100644 --- a/tests/boundary/mcp/toolInputCapacityHttp.test.ts +++ b/tests/boundary/mcp/toolInputCapacityHttp.test.ts @@ -136,6 +136,7 @@ processTest( }); } else { expect(error.code).toBe("resource_constraint"); + expect(error.remediation.action).toContain("retained-evidence"); expect(error.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 }, @@ -183,6 +184,9 @@ processTest( }); const calibratedError = parseMcpToolError(calibration).error; expect(calibratedError.code).toBe("resource_constraint"); + expect(calibratedError.remediation.action).not.toContain( + "retained-evidence", + ); expect(calibratedError.details).toMatchObject({ resource: "transport", reported_limits: { max_input_elements: 1_024 }, diff --git a/tests/boundary/mcp/webRuntimeMcp.test.ts b/tests/boundary/mcp/webRuntimeMcp.test.ts index 915214b2ce..7f66b2eba9 100644 --- a/tests/boundary/mcp/webRuntimeMcp.test.ts +++ b/tests/boundary/mcp/webRuntimeMcp.test.ts @@ -1,6 +1,6 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; -import { Ajv2020 } from "ajv/dist/2020.js"; -import { expect, it, onTestFinished } from "vitest"; +import { afterAll, expect, it, onTestFinished } from "vitest"; +import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { WebRuntimeService } from "../../../src/application/WebRuntimeService.js"; import { recordingWebRuntimePort } from "../../../src/application/WebRuntimeService.fixture.js"; import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRuntimeProvider.js"; @@ -11,11 +11,16 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { + negativeColumnScript, runtimeSourceA, runtimeSourceB, + startNegativeColumnBrowser, startRuntimeBrowser, } from "../../fixtures/webRuntime.js"; +const schemaValidation = createMcpSchemaValidation(); +afterAll(() => schemaValidation.clear()); + it("advertises actual SDK schemas, instrumentation effects and session-owned inline runtime evidence", async () => { const session = createTestBinarySession(() => { throw new Error("Binary provider must not start"); @@ -26,7 +31,10 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl webRuntime: new WebRuntimeService(recordingWebRuntimePort()), }, ); - const client = new Client({ name: "web-runtime-contract", version: "1" }); + const client = new Client( + { name: "web-runtime-contract", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); @@ -37,7 +45,7 @@ it("advertises actual SDK schemas, instrumentation effects and session-owned inl await server.connect(serverTransport); await client.connect(clientTransport); const tools = (await client.listTools()).tools; - const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const ajv = schemaValidation.assertions; const input = { cdp_endpoint: "http://127.0.0.1:9222", target_id: "allowed-page", @@ -116,7 +124,10 @@ cliTest( { kind: "session", session }, { webRuntime: new WebRuntimeService(provider) }, ); - const client = new Client({ name: "runtime-frame-identity", version: "1" }); + const client = new Client( + { name: "runtime-frame-identity", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); @@ -179,6 +190,94 @@ cliTest( 20_000, ); +cliTest( + "delivers negative columns Chrome reports through MCP and the compiled CLI", + async ({ cli }) => { + const browser = await startNegativeColumnBrowser(); + const provider = new CdpWebRuntimeProvider(); + const session = createTestBinarySession(() => { + throw new Error("Binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { webRuntime: new WebRuntimeService(provider) }, + ); + const client = new Client( + { name: "runtime-negative-columns", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); + onTestFinished(async () => { + await client.close(); + await server.close(); + await provider.close(); + await session.close(); + await browser.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const advertised = (await client.listTools()).tools.find( + ({ name }) => name === "inspect_web_event_listeners", + )?.outputSchema; + if (advertised === undefined) + throw new Error("Missing advertised listener output schema"); + const outputSchema: Record = advertised; + const response = await client.callTool({ + name: "inspect_web_event_listeners", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + allowed_origins: [browser.allowedOrigin], + selector: "#selected", + }, + }); + expect(response.isError).not.toBe(true); + const ajv = schemaValidation.assertions; + expect( + ajv.validate(outputSchema, response.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + const cliResult = await cli.run({ + arguments: [ + "inspect-web-event-listeners", + browser.endpoint, + "allowed-page", + "#selected", + "--allowed-origins", + browser.allowedOrigin, + "--json", + ], + environment: process.env, + }); + expect(cliResult.exitCode).toBe(0); + for (const evidence of [ + toolContract("inspect_web_event_listeners").outputSchema.parse( + response.structuredContent, + ), + cliResult.json, + ]) + expect(parseEvidence(evidence).normalized_result).toMatchObject({ + listeners: [ + { + location: { + script_id: negativeColumnScript.scriptId, + line_number: 21, + column_number: -50, + }, + }, + ], + sources: [ + { + script_id: negativeColumnScript.scriptId, + resource_start: { line_number: 21, column_number: -60 }, + }, + ], + }); + }, + 20_000, +); + it.each([ { name: "observe_web_execution" as const, @@ -203,7 +302,10 @@ it.each([ { kind: "session", session }, { webRuntime: new WebRuntimeService(new CdpWebRuntimeProvider()) }, ); - const client = new Client({ name: "web-runtime-partial", version: "1" }); + const client = new Client( + { name: "web-runtime-partial", version: "1" }, + { jsonSchemaValidator: schemaValidation }, + ); onTestFinished(async () => { await client.close(); await server.close(); diff --git a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts index 7660265016..4cb6188fbc 100644 --- a/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts +++ b/tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts @@ -356,9 +356,11 @@ describe("MCP composed workflow snapshot replay", () => { arguments: {}, }); expect(retained.isError, JSON.stringify(retained.content)).not.toBe(true); - const retainedBundle = toolContract( - "get_evidence_bundle", - ).outputSchema.parse(retained.structuredContent).result; + const retainedBundle = evidenceBundleSchema.parse( + toolContract("get_evidence_bundle").outputSchema.parse( + retained.structuredContent, + ).result, + ); expect( retainedBundle.records.find( ({ evidence_id }) => evidence_id === primitiveEvidence.evidence_id, @@ -551,6 +553,7 @@ describe("MCP investigation history snapshot replay", () => { replay, "investigation-history-replay", ); + await expectSnapshotImportGuidance(replayMcp, snapshotPath); const reopened = await replayMcp.callTool({ name: "open_binary", arguments: { path: firstPath, snapshot_path: snapshotPath }, @@ -575,6 +578,79 @@ describe("MCP investigation history snapshot replay", () => { ); }); +const expectSnapshotImportGuidance = async (mcp: Client, path: string) => { + const before = await readFile(path); + const imported = await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path }, + }); + const error = parseMcpToolError(imported).error; + expect(error.code).toBe("invalid_request"); + expect(error.remediation.action).toContain("open_binary"); + expect(error.remediation.action).toContain("snapshot_path"); + expect(error.remediation.action).toContain("--snapshot"); + expect(error.remediation.action).toContain("loads and updates"); + expect(error.remediation.action).toContain("not been validated"); + expect((await readFile(path)).equals(before)).toBe(true); +}; + +it("does not authenticate snapshot-shaped or unrelated malformed bundle input", async () => { + const { snapshotPath } = await createWorkflowFiles( + "rea-mcp-snapshot-kind-", + [], + ); + const { mcp } = await connectWorkflowMcp( + createWorkflowSession(makeProvider([], [])), + "snapshot-kind", + ); + await writeFile( + snapshotPath, + JSON.stringify({ + target: {}, + binding: {}, + evidence_bundle: {}, + entries: [], + workflow_entries: [], + }), + ); + const snapshotLike = parseMcpToolError( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: snapshotPath }, + }), + ).error; + expect(snapshotLike.code).toBe("invalid_request"); + expect(snapshotLike.remediation.action).toContain("not been validated"); + + await writeFile( + snapshotPath, + JSON.stringify({ + target: {}, + binding: {}, + evidence_bundle: {}, + entries: [], + workflow_entries: [], + records: [], + }), + ); + const ambiguousBundle = parseMcpToolError( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: snapshotPath }, + }), + ).error; + expect(ambiguousBundle.code).toBe("evidence_integrity_mismatch"); + + await writeFile(snapshotPath, JSON.stringify({ records: [] })); + const malformedBundle = parseMcpToolError( + await mcp.callTool({ + name: "import_evidence_bundle", + arguments: { path: snapshotPath }, + }), + ).error; + expect(malformedBundle.code).toBe("evidence_integrity_mismatch"); +}); + const createWorkflowFiles = async ( prefix: string, filenames: readonly string[], diff --git a/tests/boundary/mcp/workflowUnknownPublication.test.ts b/tests/boundary/mcp/workflowUnknownPublication.test.ts new file mode 100644 index 0000000000..03721ff389 --- /dev/null +++ b/tests/boundary/mcp/workflowUnknownPublication.test.ts @@ -0,0 +1,120 @@ +import { join } from "node:path"; +import { writeFile } from "node:fs/promises"; + +import { expect, it, vi } from "vitest"; + +import { BinarySession } from "../../../src/application/binary/BinarySession.js"; +import { InvestigationRecords } from "../../../src/application/investigation/InvestigationRecords.js"; +import { + AnalysisCapabilityUnavailableError, + AnalysisResourceConstraintError, +} from "../../../src/domain/analysisErrorCore.js"; +import { err } from "../../../src/domain/result.js"; +import { observed } from "../../fixtures/analysisExecution.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestProviderRouter } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each(["compact", "full"] as const)( + "reports the committed Unknown prefix and retained trace through MCP (%s)", + async (profile) => { + const directory = await createTestTempDirectory("rea-unknown-publication-"); + const target = join(directory, "fixture.com"); + await writeFile(target, Buffer.from([0xb8, 0x00, 0x4c, 0xcd, 0x21])); + const session = new BinarySession( + createTestProviderRouter(() => ({ + execute: async (name) => { + switch (name) { + case "health": + return observed(null); + case "search_strings": + return observed([]); + case "search_procedures": + return observed([{ address: "0x10", value: "goal" }]); + case "xrefs": + return observed(["0x20", "0x30", "0x40"]); + case "resolve_containing_procedure": + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "not supported", + ), + ); + default: + throw new Error(`Unexpected operation ${name}`); + } + }, + close: async () => ({ ok: true, value: null }), + })), + new InvestigationRecords(), + ); + const original = session.recordUnknown.bind(session); + let attempts = 0; + vi.spyOn(session, "recordUnknown").mockImplementation((input) => { + attempts += 1; + return attempts === 2 + ? err( + new AnalysisResourceConstraintError( + "record_unknown", + "capacity", + "fixture limit", + null, + ), + ) + : original(input); + }); + const { client } = await createApplicationMcpHarness({ + session, + serverOptions: { + environment: { + ...process.env, + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + REA_MCP_OUTPUT_SCHEMA_PROFILE: profile, + }, + }, + }); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path: target, format: "dos-com" }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const progress: string[] = []; + const reply = await client.callTool( + { name: "trace_feature", arguments: { query: "goal" } }, + { + onprogress: (update) => { + if (update.message) progress.push(update.message); + }, + }, + ); + const projected = parseMcpToolError(reply).error; + expect(projected.code).toBe("resource_constraint"); + expect(projected.details?.workflow_unknown_publication).toMatchObject({ + evidence_retention: "retained", + total_questions: 3, + created_questions: 1, + existing_questions: 0, + next_question_index: 1, + }); + expect(projected.remediation.action).toContain("get_evidence_bundle"); + expect(progress.at(-1)).toContain("failed"); + const retained = session.exportEvidenceBundle(); + const trace = retained.records.find( + (record) => record.operation === "trace_feature", + ); + expect(trace).toBeDefined(); + expect(projected.details?.workflow_unknown_publication).toMatchObject({ + evidence_id: trace?.evidence_id, + }); + expect(trace?.normalized_result).toMatchObject({ + references: expect.arrayContaining([ + expect.objectContaining({ source_address: "0x20" }), + expect.objectContaining({ source_address: "0x30" }), + expect.objectContaining({ source_address: "0x40" }), + ]), + }); + expect(session.listUnknowns()).toHaveLength(1); + }, +); diff --git a/tests/boundary/mcp/wxapkgMcp.test.ts b/tests/boundary/mcp/wxapkgMcp.test.ts new file mode 100644 index 0000000000..3d1901421a --- /dev/null +++ b/tests/boundary/mcp/wxapkgMcp.test.ts @@ -0,0 +1,87 @@ +import { readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { artifactInspectionResultSchema } from "../../../src/domain/artifactInspection.js"; +import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { buildWxapkg, wxapkgEncryptedBytes } from "../../fixtures/wxapkg.js"; + +it.each(["full", "compact"] as const)( + "retains wxapkg facts and encrypted coverage through compiled MCP (%s)", + async (profile) => { + const root = await createTestTempDirectory("rea-wxapkg-mcp-"); + const path = join(root, "package.wxapkg"); + const payload = Buffer.from("module.exports={};"); + await writeFile( + path, + buildWxapkg([ + { path: "app-config.json", data: "{}" }, + { path: "pages/index/index.js", data: payload }, + ]), + ); + const { call } = await connectLocalToolsMcp({ + REA_MCP_INPUT_SCHEMA_PROFILE: profile, + }); + let outputRoot: string | undefined; + try { + expect((await call("open_binary", { path })).isError).not.toBe(true); + const reply = await call("inspect_artifact", {}); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + const observed = inspection.substeps[0]!.evidence.normalized_result; + expect(observed.occurrences).toContainEqual( + expect.objectContaining({ + logical_path: "pages/index/index.js", + hash_status: "verified", + source_location: expect.objectContaining({ + offset: expect.any(Number), + }), + }), + ); + const extracted = await call("extract_artifact", {}); + expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true); + const result = artifactExtractionResultSchema.parse( + parseEvidence(extracted.structuredContent).normalized_result, + ); + outputRoot = result.output_root; + expect( + (await readFile(join(outputRoot, "pages/index/index.js"))).equals( + payload, + ), + ).toBe(true); + await call("close_binary", {}); + // Encrypted PC-client packages remain represented without member guesses. + await writeFile(path, wxapkgEncryptedBytes()); + await call("open_binary", { path }); + const unknownReply = await call("inspect_artifact", {}); + expect(unknownReply.isError, JSON.stringify(unknownReply)).not.toBe(true); + const unknown = artifactInspectionResultSchema.parse( + parseEvidence(unknownReply.structuredContent).normalized_result, + ).substeps[0]!.evidence.normalized_result; + expect(unknown.coverage).toMatchObject({ status: "unknown" }); + expect(unknown.occurrences).toMatchObject([ + { + artifact_format: "wxapkg", + encrypted: true, + format_metadata: { + kind: "wxapkg", + directory_status: "encrypted", + file_count: null, + }, + }, + ]); + const refused = await call("extract_artifact", {}); + expect(refused.isError).toBe(true); + expect(JSON.stringify(refused)).toContain("cannot be extracted"); + await call("close_binary", {}); + } finally { + if (outputRoot !== undefined) + await rm(outputRoot, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/tests/boundary/process/browserRequestVerifierOwnership.test.ts b/tests/boundary/process/browserRequestVerifierOwnership.test.ts index 12bf0c9136..a51805cbb2 100644 --- a/tests/boundary/process/browserRequestVerifierOwnership.test.ts +++ b/tests/boundary/process/browserRequestVerifierOwnership.test.ts @@ -13,7 +13,7 @@ import { processTest } from "../../support/process/processFixture.js"; // Records once if the lane's root disappears while this stand-in browser still runs. const browserSource = ` import { appendFileSync, existsSync, readdirSync } from "node:fs"; -const rootExists = () => readdirSync(process.env.OWNER_TMP).some((name) => name.startsWith("rea-request-e2e-")); +const rootExists = () => readdirSync(process.env.OWNER_TMP).some((name) => name.startsWith(process.env.OWNER_ROOT_PREFIX)); let seen = false; setInterval(() => { if (existsSync(process.env.OWNER_RELEASE)) process.exit(0); @@ -77,82 +77,105 @@ const releaseBrowser = async (release: string, ready: string) => { // The stand-in CLI and browser are authored; the lane, its owner, the private root // and process cleanup are real. No browser capture is simulated or claimed. -processTest.skipIf(process.platform === "win32")( - "stops a failed capture's CLI and the browser it launched before removing the request lane's root", - { timeout: 40_000 }, - async ({ processes }) => { - const root = await createTestTempDirectory("rea-browser-request-owner-"); - const tmp = join(root, "tmp"); - await mkdir(tmp); - const cli = join(root, "cli.mjs"); - const browser = join(root, "browser.mjs"); - const ready = join(root, "ready.json"); - const release = join(root, "release-browser"); - const events = join(root, "events.jsonl"); - const driver = join(root, "driver.mjs"); - await writeFile(cli, cliSource); - await writeFile(browser, browserSource); - const lane = pathToFileURL( - resolve("scripts/lib/browser-request-e2e.mjs"), - ).href; - const observations = pathToFileURL( - resolve("dist/process/ProcessOwnershipObservation.js"), - ).href; - const tree = pathToFileURL( - resolve("dist/process/ProcessOwnershipProcessTree.js"), - ).href; - await writeFile( - driver, - ` +for (const { name, module, verify, rootPrefix } of [ + { + name: "request", + module: "browser-request-e2e.mjs", + verify: "verifyBrowserRequestSteps", + rootPrefix: "rea-request-e2e-", + }, + { + name: "source-map", + module: "browser-source-map-e2e.mjs", + verify: "verifyBrowserSourceMap", + rootPrefix: "rea-browser-source-map-", + }, +]) { + processTest.skipIf(process.platform === "win32")( + `stops a failed capture's CLI and browser before removing the ${name} lane's root`, + { timeout: 40_000 }, + async ({ processes }) => { + const root = await createTestTempDirectory(`rea-browser-${name}-owner-`); + const tmp = join(root, "tmp"); + await mkdir(tmp); + const cli = join(root, "cli.mjs"); + const browser = join(root, "browser.mjs"); + const ready = join(root, "ready.json"); + const release = join(root, "release-browser"); + const events = join(root, "events.jsonl"); + const driver = join(root, "driver.mjs"); + const compiler = join(root, "compiler.mjs"); + // This lifecycle probe stops at CLI failure, before browser or map proof. + await writeFile( + compiler, + 'export const version = "0.25.10"; export const transform = async (code) => ({ code, map: "{}" }); export const stop = () => {};', + ); + await writeFile(cli, cliSource); + await writeFile(browser, browserSource); + const lane = pathToFileURL(resolve("scripts/lib", module)).href; + const observations = pathToFileURL( + resolve("dist/process/ProcessOwnershipObservation.js"), + ).href; + const tree = pathToFileURL( + resolve("dist/process/ProcessOwnershipProcessTree.js"), + ).href; + await writeFile( + driver, + ` import { readFile, readdir } from "node:fs/promises"; -import { verifyBrowserRequestSteps } from ${JSON.stringify(lane)}; +import { ${verify} } from ${JSON.stringify(lane)}; import { systemProcessOwnershipHost } from ${JSON.stringify(observations)}; import { liveProcesses } from ${JSON.stringify(tree)}; let failure; try { - await verifyBrowserRequestSteps("/rea-unlaunched-browser", process.env.OWNER_CLI); + await ${verify}("/rea-unlaunched-browser", process.env.OWNER_CLI); } catch (cause) { failure = { name: cause?.name ?? typeof cause, message: cause?.message ?? String(cause) }; } const { pids } = JSON.parse(await readFile(process.env.OWNER_READY, "utf8")); const live = liveProcesses(await systemProcessOwnershipHost.listProcesses()).filter(({ pid }) => pids.includes(pid)).map(({ pid }) => pid); -const roots = (await readdir(process.env.OWNER_TMP)).filter((name) => name.startsWith("rea-request-e2e-")); +const roots = (await readdir(process.env.OWNER_TMP)).filter((name) => name.startsWith(process.env.OWNER_ROOT_PREFIX)); process.stdout.write(JSON.stringify({ pids, live, roots, failure })); `, - ); - try { - const result = await processes.run(process.execPath, [driver], { - timeoutMs: 30_000, - env: { - ...process.env, - TMPDIR: tmp, - OWNER_TMP: tmp, - OWNER_CLI: cli, - OWNER_BROWSER_ENTRY: browser, - OWNER_READY: ready, - OWNER_RELEASE: release, - OWNER_EVENTS: events, - }, - }); - expect(result.exitCode, result.stderr).toBe(0); - const report = reportSchema.parse(JSON.parse(result.stdout)); - expect(report.live).toEqual([]); - const receipts = await readFile(events, "utf8").catch( - (cause: unknown) => { - if ( - cause instanceof Error && - "code" in cause && - cause.code === "ENOENT" - ) - return ""; - throw cause; - }, ); - expect(receipts).toBe(""); - expect(report.roots).toEqual([]); - expect(report.failure.message).toMatch(/output exceeded/u); - } finally { - await releaseBrowser(release, ready); - } - }, -); + try { + const result = await processes.run(process.execPath, [driver], { + timeoutMs: 30_000, + env: { + ...process.env, + TMPDIR: tmp, + OWNER_TMP: tmp, + OWNER_ROOT_PREFIX: rootPrefix, + REA_WEB_SOURCE_MAP_COMPILER: compiler, + OWNER_CLI: cli, + OWNER_BROWSER_ENTRY: browser, + OWNER_READY: ready, + OWNER_RELEASE: release, + OWNER_EVENTS: events, + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = reportSchema.parse(JSON.parse(result.stdout)); + expect(report.live).toEqual([]); + const receipts = await readFile(events, "utf8").catch( + (cause: unknown) => { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) + return ""; + throw cause; + }, + ); + expect(receipts).toBe(""); + expect(report.roots).toEqual([]); + expect(report.failure.message).toMatch( + /output exceeded|maxBuffer length exceeded/u, + ); + } finally { + await releaseBrowser(release, ready); + } + }, + ); +} diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 247d3e3639..63f77789c4 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1317,6 +1317,29 @@ it.skipIf(!onDarwin)( missingAppleBoundaryFailsClosed: true, malformedBuffersRejected: true, invalidPointerSizeFailsClosed: true, + incompleteRestrictedArguments: [ + "malformed_procargs_path_padding", + "malformed_procargs_arguments", + "apple_vector_unavailable", + ].map((reason) => [ + { + state: "unavailable", + reason: "platform_binary_environment_withheld", + }, + { + state: "unavailable", + reason: "restricted_process_environment_withheld", + }, + { state: "unavailable", reason }, + ]), + signedReadableTokens: [true, true], + signedInvalidTokens: [ + "duplicate_run_token", + "invalid_run_token_encoding", + ].map((reason) => [ + { state: "unavailable", reason }, + { state: "unavailable", reason }, + ]), malformedStages: [ { state: "unavailable", reason: "malformed_procargs_header" }, { state: "unavailable", reason: "malformed_procargs_argument_count" }, diff --git a/tests/boundary/process/mcpVerifierResults.test.ts b/tests/boundary/process/mcpVerifierResults.test.ts index 0aa4891be6..a249e08f80 100644 --- a/tests/boundary/process/mcpVerifierResults.test.ts +++ b/tests/boundary/process/mcpVerifierResults.test.ts @@ -199,6 +199,8 @@ it("retains summary discovery in the generic lifecycle reader but refuses it as total_retained_records: 0, matching_records: 0, unknown_revisions: 0, + direct_links: 0, + missing_direct_links: 0, records: [], }; expect( diff --git a/tests/boundary/process/nativeUiVerifierLane.test.ts b/tests/boundary/process/nativeUiVerifierLane.test.ts new file mode 100644 index 0000000000..5ddf216c58 --- /dev/null +++ b/tests/boundary/process/nativeUiVerifierLane.test.ts @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { systemProcessOwnershipHost } from "../../../src/process/ProcessOwnershipObservation.js"; +import { liveProcesses } from "../../../src/process/ProcessOwnershipProcessTree.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +// Stands in for /usr/bin/open: starts a detached app that inherits its environment. +// Each stand-in records itself and leaves when the test releases it, or expires. +const standInPrelude = ` +import { appendFileSync, existsSync, readFileSync } from "node:fs"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const record = (kind) => appendFileSync(config.events, JSON.stringify({ kind, pid: process.pid }) + "\\n"); +setInterval(() => { + if (existsSync(config.release)) process.exit(0); +}, 10); +setTimeout(() => process.exit(0), 60000); +`; + +const launcherSource = ` +import { spawn } from "node:child_process"; +import { openSync } from "node:fs"; +${standInPrelude} +record("launcher"); +const args = process.argv.slice(2); +const coordinates = args[args.indexOf("--stdout") + 1]; +spawn(process.execPath, [new URL("./fixture.mjs", import.meta.url).pathname], { + detached: true, + stdio: ["ignore", openSync(coordinates, "a"), "ignore"], +}).unref(); +`; + +const fixtureSource = ` +import { writeSync } from "node:fs"; +${standInPrelude} +record("fixture"); +writeSync(1, JSON.stringify({ pid: process.pid, window_id: 7 }) + "\\n"); +`; + +// Runs the actual verifier and observation mapping. Only the compiler and launcher +// are replaced: the compiled helper is an authored script answering as the real +// helper does, so this covers the lane's assertions and not native capture. +const preloadSource = ` +import { chmodSync, copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { basename } from "node:path"; +import { promisify } from "node:util"; +const config = JSON.parse(readFileSync(new URL("./config.json", import.meta.url), "utf8")); +const childProcess = createRequire(import.meta.url)("node:child_process"); +const { execFile, spawn } = childProcess; +const script = (path, body) => { + writeFileSync(path, "#!/bin/sh\\n" + body + "\\n"); + chmodSync(path, 0o755); +}; +const compile = (args) => { + const output = args[args.indexOf("-o") + 1]; + if (basename(output) === "fixture") copyFileSync("/usr/bin/true", output); + else if (basename(output) === "child-retrieval-test") + script(output, "printf '%s\\\\n' '{\\"a\\":\\"b\\"}' '{\\"n\\":1.0}'"); + else script(output, config.helper); +}; +const compiled = (file, args) => file === "/usr/bin/xcrun" && args.includes("-o"); +childProcess.execFile = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile(file, args, ...rest); + compile(args); + const done = rest.at(-1); + queueMicrotask(() => done(null, "", "")); + return spawn("/usr/bin/true", [], { stdio: "ignore" }); +}; +childProcess.execFile[promisify.custom] = (file, args, ...rest) => { + if (!compiled(file, args)) return execFile[promisify.custom](file, args, ...rest); + compile(args); + return Promise.resolve({ stdout: "", stderr: "" }); +}; +childProcess.spawn = (file, args, options) => { + if (compiled(file, args)) { + compile(args); + return spawn("/usr/bin/true", [], options); + } + return file === "/usr/bin/open" + ? spawn(process.execPath, [config.launcherEntry, ...args], options) + : spawn(file, args, options); +}; +syncBuiltinESMExports(); +`; + +const denied = `printf '%s' '{"ok":false,"code":"screen-recording-denied","message":"authored permission denial"}'`; +const answersChangedBytes = `case "$1" in *'"sha256":"0000000000'*) printf '%s' '{"ok":false,"code":"target-mismatch","message":"authored digest refusal"}'; exit 0;; esac; ${denied}`; + +const standInSchema = z.object({ + kind: z.enum(["launcher", "fixture"]), + pid: z.number().int().positive(), +}); + +const runVerifier = async (helper: string, flags: readonly string[]) => { + const workspace = await createTestTempDirectory("rea-native-ui-lane-"); + const paths = { + events: join(workspace, "events.jsonl"), + release: join(workspace, "release"), + launcherEntry: join(workspace, "launcher.mjs"), + }; + await writeFile(paths.events, ""); + await writeFile(paths.launcherEntry, launcherSource); + await writeFile(join(workspace, "fixture.mjs"), fixtureSource); + await writeFile(join(workspace, "preload.mjs"), preloadSource); + await writeFile( + join(workspace, "config.json"), + JSON.stringify({ ...paths, helper }), + ); + const child = spawn( + process.execPath, + [ + "--import", + pathToFileURL(join(workspace, "preload.mjs")).href, + resolve("scripts/verify-native-ui.mjs"), + ...flags, + ], + { + env: { ...process.env, TMPDIR: workspace }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const standIns = async () => + (await readFile(paths.events, "utf8")) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => standInSchema.parse(JSON.parse(line))); + const isLive = async (pid: number) => + liveProcesses(await systemProcessOwnershipHost.listProcesses()).some( + (process) => process.pid === pid, + ); + const untilGone = async (pid: number) => { + const deadline = performance.now() + 15_000; + while ((await isLive(pid)) && performance.now() < deadline) await delay(25); + return !(await isLive(pid)); + }; + // Runs on a timeout or failed assertion too: the verifier cannot release a stand-in + // once killed, and its fixture would otherwise outlive the test until it expired. + onTestFinished(async () => { + child.kill("SIGKILL"); + await writeFile(paths.release, ""); + for (const { pid } of await standIns()) + expect(await untilGone(pid)).toBe(true); + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + const code = await new Promise((settle) => { + child.once("close", settle); + }); + const fixture = (await standIns()).find(({ kind }) => kind === "fixture"); + expect(fixture, stderr).toBeDefined(); + return { + code, + stdout, + stderr, + fixtureLive: !(await untilGone(fixture?.pid ?? -1)), + roots: (await readdir(workspace)).filter((name) => + name.startsWith("rea-ui-fixture-"), + ), + }; +}; + +// The verifier refuses other hosts before any fixture is created. +const macosIt = it.skipIf(process.platform !== "darwin"); + +macosIt( + "completes at the permission boundary when the helper refuses changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, [ + "--allow-permission-denial", + ]); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + verification_status: "permission-boundary-only", + positive_e2e: false, + mismatch_rejected: true, + }); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "fails when the helper does not refuse changed target bytes", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(denied, ["--allow-permission-denial"]); + expect(result.code).toBe(1); + expect(result.stderr).toContain( + "Real helper did not reject changed target bytes", + ); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); + +macosIt( + "keeps a permission denial a failure in the positive lane", + { timeout: 30_000 }, + async () => { + const result = await runVerifier(answersChangedBytes, []); + expect(result.code).toBe(1); + expect(result.stderr).toContain("requires successful capture"); + expect(result.stdout).toBe(""); + expect(result.fixtureLive).toBe(false); + expect(result.roots).toEqual([]); + }, +); diff --git a/tests/boundary/process/nativeValueVerifierComparison.test.ts b/tests/boundary/process/nativeValueVerifierComparison.test.ts new file mode 100644 index 0000000000..f73878257a --- /dev/null +++ b/tests/boundary/process/nativeValueVerifierComparison.test.ts @@ -0,0 +1,102 @@ +import { fileURLToPath } from "node:url"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { processTest } from "../../support/process/processFixture.js"; + +const moduleUrl = (path: string) => + JSON.stringify(new URL(`../../../${path}`, import.meta.url).href); + +processTest.for([ + { coverage: "complete", status: "unchanged" }, + { coverage: "partial", status: "unknown" }, +] as const)( + "compares retained $coverage function Evidence within default MCP limits", + async ({ coverage, status }, { processes }) => { + const root = await createTestTempDirectory("rea-verifier-comparison-"); + const source = ` + import assert from "node:assert/strict"; + import { writeFile } from "node:fs/promises"; + import { join } from "node:path"; + import { Client } from "@modelcontextprotocol/client"; + import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + import { FUNCTION_COMPARISON_EXAMPLE } from ${moduleUrl("dist/contracts/functionComparisonExample.js")}; + import { createEvidence } from ${moduleUrl("dist/domain/evidence.js")}; + import { createEvidenceBundle } from ${moduleUrl("dist/domain/evidenceBundle.js")}; + import { functionDossierSchema } from ${moduleUrl("dist/domain/native/functionDossier.js")}; + import { compareRetainedFunctionEvidence } from ${moduleUrl("scripts/lib/native-value-e2e.mjs")}; + import { requireMcpEvidenceBundle } from ${moduleUrl("scripts/lib/mcp-verifier-results.mjs")}; + + // Explicit synthetic facts qualify this adapter boundary, not real Ghidra. + const dossier = functionDossierSchema.parse( + structuredClone(FUNCTION_COMPARISON_EXAMPLE.left.normalized_result)); + dossier.assembly = Array.from({ length: 1200 }, (_, i) => "synthetic instruction " + i); + const evidence = createEvidence({ path: "/synthetic/function.elf", + sha256: "a".repeat(64), format: "elf", architecture: "x86_64" }, + { id: "synthetic-verifier-facts", name: "Explicit synthetic facts", version: "1" }, + { operation: "analyze_function", parameters: { procedure: "main" }, result: dossier, + relationshipCoverage: { status: ${JSON.stringify(coverage)}, + ...(${JSON.stringify(coverage)} === "partial" + ? { limitations: ["Explicit synthetic unresolved relationships."] } : {}) } }); + const bundlePath = join(process.argv[1], "source.json"); + await writeFile(bundlePath, JSON.stringify(createEvidenceBundle([evidence], []))); + const transport = new StdioClientTransport({ command: process.execPath, + args: [${JSON.stringify(fileURLToPath(new URL("../../../scripts/rea.mjs", import.meta.url)))}, "mcp"], + env: { PATH: process.env.PATH ?? "", REA_LOG_LEVEL: "silent", + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper" }, stderr: "pipe" }); + const client = new Client({ name: "synthetic-verifier-comparison", version: "1" }); + try { + await client.connect(transport); + const imported = await client.callTool({ name: "import_evidence_bundle", + arguments: { path: bundlePath } }); + assert.notEqual(imported.isError, true, JSON.stringify(imported)); + const inline = await client.callTool({ name: "compare_functions", + arguments: { left: evidence, right: evidence } }); + assert.equal(inline.isError, true, JSON.stringify(inline)); + const error = JSON.parse(inline.content.find(item => item.type === "text").text).error; + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.reported_limits.max_input_elements, 1024); + + // Execute the same request producer used by both real-provider controls. + const compared = await compareRetainedFunctionEvidence(client, evidence); + assert.equal(compared.normalized_result.status, ${JSON.stringify(status)}); + assert.ok(compared.evidence_links.includes(evidence.evidence_id)); + const bundle = requireMcpEvidenceBundle(await client.callTool({ + name: "get_evidence_bundle", arguments: {} })); + for (const record of [evidence, compared]) + assert.deepEqual(bundle.records.find(item => item.evidence_id === record.evidence_id), record); + const missingId = "ev_" + "b".repeat(64); + let missingFailure; + const checkedClient = { callTool: async (...args) => { + const reply = await client.callTool(...args); + assert.equal(reply.isError, true); + missingFailure = JSON.parse(reply.content.find(item => item.type === "text").text).error; + return reply; + } }; + await assert.rejects(() => compareRetainedFunctionEvidence(checkedClient, + { ...evidence, evidence_id: missingId }), { name: "AssertionError" }); + assert.equal(missingFailure.details.reason, "missing"); + assert.equal(missingFailure.details.evidence_id, missingId); + assert.match(missingFailure.message, /not available from the selected source/); + console.log(JSON.stringify({ status: compared.normalized_result.status, + exact_source_link: true, complete_records_retained: true, missing_reference_refused: true })); + } finally { + await client.close(); + await transport.close(); + } + `; + const result = await processes.run(process.execPath, [ + "--input-type=module", + "--eval", + source, + root, + ]); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(JSON.parse(result.stdout.trim())).toEqual({ + status, + exact_source_link: true, + complete_records_retained: true, + missing_reference_refused: true, + }); + }, +); diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index 155f7457e1..c46a66d7ab 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -9,8 +9,14 @@ import { spawn as spawnPty } from "@lydell/node-pty"; import { expect, it, onTestFinished, vi } from "vitest"; +import { + OwnedDebuggerCleanupError, + retainedDebuggerProcessIds, + retainedDebuggerRemediation, +} from "../../../src/process/ownedDebuggerCleanupError.js"; import { closeOwnedDebuggerSessions, + type OwnedDebuggerStopResult, stopOwnedDebuggerProcess, verifyOwnedInferiorsEnded, } from "../../../src/process/ownedDebuggerSession.js"; @@ -29,6 +35,15 @@ const createFakeSupervisor = () => ({ limitAdditionalOutputBytes: vi.fn(), dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), +}); + +/** A session reduced to what closing all sessions reads from it. */ +const closableSession = (id: string, pid?: number) => ({ + id, + launched: { process: { pid } }, + supervisor: { ref: vi.fn(), unref: vi.fn() }, }); const startStopProbe = (source: string, pipeInput = false) => { @@ -533,7 +548,7 @@ it("retains an exited debugger after provider detach failed", async () => { get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: async () => { throw new Error("MI detach failed"); }, @@ -545,6 +560,8 @@ it("retains an exited debugger after provider detach failed", async () => { disposals += 1; }, unrefOutputStreams: () => {}, + ref: () => {}, + unref: () => {}, }, }; const sessions = new Map([[session.id, session]]); @@ -580,7 +597,7 @@ it("recovers failed detach only after the identified inferior ends and output se get exited() { return exited; }, - launched: { process: { kill: vi.fn(() => true) } }, + launched: { process: { pid: 101, kill: vi.fn(() => true) } }, detachInferior: async () => { throw new Error("detach failed"); }, @@ -765,7 +782,7 @@ it("retains an exited session while a timed-out detach attempt is still pending" get exited() { return exited; }, - launched: { process: { kill: () => true } }, + launched: { process: { pid: 101, kill: () => true } }, detachInferior: () => { detachCalls += 1; return new Promise<{ readonly status: "no-inferior" }>((resolve) => { @@ -778,6 +795,8 @@ it("retains an exited session while a timed-out detach attempt is still pending" waitForOutputClose: async () => true, dispose: vi.fn(), unrefOutputStreams: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), }, }; const sessions = new Map([[session.id, session]]); @@ -1365,8 +1384,8 @@ it.skipIf(process.platform === "win32")( it("removes stopped sessions and retains incomplete sessions when closing all", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["incomplete", closableSession("incomplete")], ]); await expect( @@ -1385,8 +1404,8 @@ it("removes stopped sessions and retains incomplete sessions when closing all", }); it("does not remove a replacement session installed under the same key while stopping", async () => { - const original = { id: "shared-key", generation: "old" }; - const replacement = { id: "shared-key", generation: "new" }; + const original = closableSession("shared-key"); + const replacement = closableSession("shared-key"); const sessions = new Map([["shared-key", original]]); let finishStop!: (result: { readonly status: "stopped" }) => void; const stopping = new Promise<{ readonly status: "stopped" }>((resolve) => { @@ -1407,7 +1426,7 @@ it("does not remove a replacement session installed under the same key while sto }); it("closes an empty owned-session collection", async () => { - const sessions = new Map(); + const sessions = new Map>(); await expect( closeOwnedDebuggerSessions( sessions, @@ -1419,10 +1438,10 @@ it("closes an empty owned-session collection", async () => { it("settles every stop and retains rejected sessions while removing successful ones", async () => { const sessions = new Map([ - ["stopped", { id: "stopped" }], - ["rejected", { id: "rejected" }], - ["string-rejected", { id: "string-rejected" }], - ["incomplete", { id: "incomplete" }], + ["stopped", closableSession("stopped")], + ["rejected", closableSession("rejected")], + ["string-rejected", closableSession("string-rejected")], + ["incomplete", closableSession("incomplete")], ]); const calls: string[] = []; @@ -1455,3 +1474,88 @@ it("settles every stop and retains rejected sessions while removing successful o "incomplete", ]); }); + +it("stops retained sessions holding the host and names them, leaving stopped peers alone", async () => { + const stopped = closableSession("stopped", 101); + const rejected = closableSession("rejected", 102); + const incomplete = closableSession("incomplete", 103); + const unidentified = closableSession("unidentified"); + const sessions = new Map( + [stopped, rejected, incomplete, unidentified].map((session) => [ + session.id, + session, + ]), + ); + + const failure: unknown = await closeOwnedDebuggerSessions( + sessions, + async (session) => { + if (session === stopped) return { status: "stopped" }; + if (session === rejected) throw new Error("signal failed"); + return { status: "incomplete", reason: "still alive" }; + }, + "Rizin", + ).catch((cause: unknown) => cause); + + expect(failure).toBeInstanceOf(OwnedDebuggerCleanupError); + expect(failure).toBeInstanceOf(AggregateError); + expect((failure as OwnedDebuggerCleanupError).retainedProcessIds).toEqual([ + 102, 103, + ]); + expect(stopped.supervisor.unref).not.toHaveBeenCalled(); + for (const retained of [rejected, incomplete, unidentified]) + expect(retained.supervisor.unref).toHaveBeenCalledOnce(); + expect([...sessions.keys()]).toEqual([ + "rejected", + "incomplete", + "unidentified", + ]); +}); + +it("holds the host again while retrying a retained session and leaves it held on success", async () => { + const session = closableSession("retained", 101); + const sessions = new Map([[session.id, session]]); + const order: string[] = []; + session.supervisor.ref.mockImplementation(() => order.push("ref")); + session.supervisor.unref.mockImplementation(() => order.push("unref")); + let stopResult: OwnedDebuggerStopResult = { + status: "incomplete", + reason: "still alive", + }; + const stop = async () => { + order.push("stop"); + return stopResult; + }; + + await expect( + closeOwnedDebuggerSessions(sessions, stop, "GDB"), + ).rejects.toBeInstanceOf(OwnedDebuggerCleanupError); + expect(order).toEqual(["ref", "stop", "unref"]); + + order.length = 0; + stopResult = { status: "stopped" }; + await closeOwnedDebuggerSessions(sessions, stop, "GDB"); + expect(order).toEqual(["ref", "stop"]); + expect(sessions.size).toBe(0); +}); + +it("finds retained debugger IDs through composed failures without looping on cycles", () => { + const first = new OwnedDebuggerCleanupError([], "first", [101, 102]); + const second = new OwnedDebuggerCleanupError([], "second", [102, 103]); + const cyclic = new Error("cyclic", { cause: second }); + Object.assign(second, { cause: cyclic }); + const composed = new AggregateError( + [first, new Error("wrapped", { cause: cyclic }), new Error("peer"), null], + "outer", + ); + + expect(retainedDebuggerProcessIds(composed)).toEqual([101, 102, 103]); + expect(retainedDebuggerProcessIds(new Error("peer"))).toEqual([]); + expect(retainedDebuggerProcessIds(undefined)).toEqual([]); +}); + +it("names every retained debugger in its remediation", () => { + const remediation = retainedDebuggerRemediation([4242, 4243]); + expect(remediation).toMatch(/\b4242\b/u); + expect(remediation).toMatch(/\b4243\b/u); +}); diff --git a/tests/boundary/process/processCaptureNormalization.test.ts b/tests/boundary/process/processCaptureNormalization.test.ts index 838a550a76..e21bfe8446 100644 --- a/tests/boundary/process/processCaptureNormalization.test.ts +++ b/tests/boundary/process/processCaptureNormalization.test.ts @@ -262,7 +262,7 @@ itWithCaptureCapability( ).toBe(original.replaceAll("\n", "\r\n")); expect(capture.manifest.comparison_contract).toHaveProperty( "path_normalization_version", - "path-component-boundaries-v3", + "path-component-boundaries-v4", ); const legacyContract = { ...capture.manifest.comparison_contract }; delete legacyContract.path_normalization_version; diff --git a/tests/boundary/process/processSessionMcp.test.ts b/tests/boundary/process/processSessionMcp.test.ts index 565f16a744..094757bd90 100644 --- a/tests/boundary/process/processSessionMcp.test.ts +++ b/tests/boundary/process/processSessionMcp.test.ts @@ -115,11 +115,20 @@ itWithCaptureCapability( }; const expanded = await capture("x".repeat(500)); - expect(expanded.frames).toHaveLength(0); expect(expanded.truncated).toBe(true); + const retainedNormalized = expanded.frames.map(({ data }) => data).join(""); + const retainedRaw = expanded.frames + .map(({ raw_data, data }) => raw_data ?? data) + .join(""); + // PTY read boundaries need not match the child's write. A small chunk can + // fit even after another chunk was omitted for exceeding the budget. + expect(retainedNormalized).toBe( + retainedRaw.replaceAll("a", "x".repeat(500)), + ); + expect(Buffer.byteLength(retainedNormalized)).toBeLessThanOrEqual(1_000); expect(expanded.truncation_details.normalized_terminal).toMatchObject({ budget_bytes: 1_000, - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedNormalized), omitted_frames: expect.any(Number), }); expect( @@ -128,9 +137,9 @@ itWithCaptureCapability( expect(expanded.truncation_details.raw_terminal).toMatchObject({ budget_bytes: 1_000, observed_bytes: expect.any(Number), - retained_bytes: 0, + retained_bytes: Buffer.byteLength(retainedRaw), observed_frames: expect.any(Number), - retained_frames: 0, + retained_frames: expanded.frames.length, }); expect( expanded.limitations.some((limitation) => diff --git a/tests/boundary/process/providerVerifierCleanup.test.ts b/tests/boundary/process/providerVerifierCleanup.test.ts index 1ec3be5aba..c96b3cccb2 100644 --- a/tests/boundary/process/providerVerifierCleanup.test.ts +++ b/tests/boundary/process/providerVerifierCleanup.test.ts @@ -88,9 +88,9 @@ export class FridaInstrumentationManager { } `, ); - await writeFile( + await copyFile( + new URL("../../../dist/domain/fridaRemoteAddress.js", import.meta.url), join(root, "dist", "domain", "fridaRemoteAddress.js"), - "export const isValidFridaRemoteAddress = () => false;\n", ); } else { await mkdir(join(root, "dist", "composition")); diff --git a/tests/boundary/process/regexMatcher.test.ts b/tests/boundary/process/regexMatcher.test.ts index b6b8c4c94b..75f4f65f12 100644 --- a/tests/boundary/process/regexMatcher.test.ts +++ b/tests/boundary/process/regexMatcher.test.ts @@ -583,6 +583,7 @@ it("names the rejected pattern field for a default invalid-pattern refusal", asy expect(projectAnalysisError(matched.error)).toMatchObject({ code: "invalid_request", category: "invalid_input", + remediation: { action: expect.stringMatching(/pattern.*mode literal/u) }, recovery: { kind: "inspect_input_contract", operation: "search_procedures", @@ -593,7 +594,7 @@ it("names the rejected pattern field for a default invalid-pattern refusal", asy { path: ["pattern"], reason: "invalid_format", - message: "Supply a valid regular expression.", + message: expect.stringContaining("Invalid regular expression"), }, ], }, @@ -620,6 +621,7 @@ it("keeps Hopper's result projection and invalid-pattern contract with the share code: "invalid_request", category: "invalid_input", message: expect.stringContaining("Invalid regex pattern"), + remediation: { action: expect.stringMatching(/pattern.*mode literal/u) }, recovery: { kind: "inspect_input_contract", operation: "search_strings" }, details: { operation: "search_strings", @@ -627,7 +629,7 @@ it("keeps Hopper's result projection and invalid-pattern contract with the share { path: ["pattern"], reason: "invalid_format", - message: "Supply a valid regular expression.", + message: expect.stringContaining("Invalid regular expression"), }, ], }, diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 5ddd2026ed..bf0d0754e3 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -28,10 +28,14 @@ import type { import { DEBUGGER_COMMAND_DEADLINE_SETTING, DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT, } from "../../../src/config/debuggerSessionLimits.js"; import { DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES } from "../../../src/domain/analysisResourceLimits.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; -import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { + debuggerAdmissionRecovery, + stopOwnedDebuggerProcess, +} from "../../../src/process/ownedDebuggerSession.js"; import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js"; import { expectGdbSessionStarted } from "./gdbTestHelpers.js"; @@ -3633,6 +3637,86 @@ process.stdin.on('data', (chunk) => { expect(manager.status(started.value.session_id)?.state).toBe("closed"); }); +it("keeps the session usable after an oversized stream record is dropped", async () => { + const fake = createFakeSpawner((child, token, command) => { + if (command === "-gdb-set auto-load off") { + child.stdout.write(`${token}^done\n`); + return; + } + if (command === '-interpreter-exec console "big-stream"') { + child.stdout.write('~"before"\n~"' + "x".repeat(16 * 1024 * 1024 + 1)); + child.stdout.write('"\n' + token + "^done\n(gdb)\n"); + return; + } + if (command === '-interpreter-exec console "after-drop"') { + child.stdout.write(token + "^done\n(gdb)\n"); + return; + } + if (command === "-list-thread-groups --recurse 1") { + child.stdout.write(`${token}^done,groups=[]\n`); + return; + } + if (command === "-gdb-exit") { + child.stdout.write(`${token}^done\n`); + return; + } + child.stdout.write(token + '^error,msg="unmodeled request"\n'); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const executed = await manager.execute( + started.value.session_id, + "big-stream", + ); + + expect(executed.ok).toBe(true); + if (executed.ok) { + expect(executed.value.result.mi).toMatch(/^\d+\^done$/); + expect(executed.value.result.console).toContain("before"); + expect(executed.value.result.completion_status).toBe("complete"); + expect(executed.value.result.output_truncated).toBe(true); + const output = DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT; + expect(executed.value.evidence.limitations.join(" ")).toContain( + `Retained MI records reached the ${String(output.value)} byte command-output cap or the 16384 record cap. ${debuggerAdmissionRecovery(output)} The record cap has no separate setting. Output completeness is unknown.`, + ); + } + const second = await manager.execute(started.value.session_id, "after-drop"); + expect(second.ok).toBe(true); + if (second.ok) expect(second.value.result.mi).toMatch(/^\d+\^done$/); + await expect(manager.close(started.value.session_id)).resolves.toMatchObject({ + ok: true, + }); +}); + +it("still stops the session for an oversized result record", async () => { + const manager = await managerFor(` +process.stdout.write('(gdb)\\n'); +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; + for (;;) { + const end = input.indexOf('\\n'); + if (end < 0) break; + const line = input.slice(0, end); input = input.slice(end + 1); + const match = /^(\\d+)(.*)/.exec(line); + if (!match) continue; + if (match[2] === '-gdb-set auto-load off') process.stdout.write(match[1] + '^done\\n'); + else if (match[2] === '-interpreter-exec console "bad-value"') process.stdout.write(match[1] + '^done,value="' + 'x'.repeat(16 * 1024 * 1024 + 1) + '"\\n'); + else process.stdout.write(match[1] + '^error,msg="unmodeled request"\\n'); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const failed = await manager.execute(started.value.session_id, "bad-value"); + + expect(failed.ok).toBe(false); + if (!failed.ok) + expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError"); +}); + it("caps the number of MI records retained for a single command", async () => { const recordCount = 16_385; const manager = await managerFor(` diff --git a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts index 045a9065db..91e9f5bbe9 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.fixture.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.fixture.ts @@ -29,6 +29,9 @@ type FixtureMode = | "remote_error" | "decompile_timed_out" | "remote_startup_error" + | "import_identity_unavailable" + | "import_identity_mismatch" + | "late_identity_failure" | "shutdown_error" | "invalid_shutdown_ack" | "hang_after_start" diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index a07c00c5c4..6e481ad4af 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -29,6 +29,7 @@ import type { ProgressUpdate } from "../../../../src/application/ProgressReporte import { err, ok } from "../../../../src/domain/result.js"; import * as GhidraTransport from "../../../../src/ghidra/GhidraTransport.js"; import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; +import { GhidraAdmissionError } from "../../../../src/ghidra/GhidraAdmissionError.js"; import { ProviderRunLineage } from "../../../../src/process/ProviderRunLineage.js"; import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; @@ -1017,6 +1018,45 @@ it("retains launcher diagnostics when an input error rejects the startup handsha expect(result.error.diagnostics).not.toHaveProperty("diagnostic_scope"); }); +it.each( + ( + ["import_identity_unavailable", "import_identity_mismatch"] as const + ).flatMap((code) => + (HOST_TRANSPORT === "unix-socket" + ? ([HOST_TRANSPORT, "authenticated-loopback-tcp"] as const) + : ([HOST_TRANSPORT] as const) + ).map((transport) => [code, transport] as const), + ), +)( + "retains structured %s from %s startup and cleans the failed bridge", + async (code, transport) => { + const launcher = new FixtureLauncher(code); + const client = clientFor(launcher, { transport }); + const result = await client.start(); + if (result.ok) throw new Error("Expected identity admission failure"); + expect(result.error.kind).toBe("admission"); + expect(result.error.cause).toBeInstanceOf(GhidraAdmissionError); + expect(result.error.cause).toMatchObject({ code }); + expect(client.isReady()).toBe(false); + await expect(waitForExit(launcher.processes[0])).resolves.toBe(true); + expect(launcher.requests).toEqual(["ping", "shutdown"]); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("does not classify a later remote error as an identity admission failure", async () => { + const client = clientFor(new FixtureLauncher("late_identity_failure")); + expect((await client.start()).ok).toBe(true); + const result = await client.callTool("list_strings", {}); + if (result.ok) throw new Error("Expected the fixture's later remote failure"); + expect(result.error.kind).toBe("remote"); + expect(result.error.remoteCode).toBe("import_identity_mismatch"); + expect(result.error.cause).not.toBeInstanceOf(GhidraAdmissionError); + expect(client.isReady()).toBe(true); +}); + describe("GhidraClient", () => { it("completes an exact, fragmented post-analysis handshake", async () => { const launcher = new FixtureLauncher("fragmented"); @@ -2073,10 +2113,73 @@ describe("GhidraClient shutdown diagnostics", () => { ); }); -it("preserves failed startup and retained Ghidra cleanup capabilities until a verified retry", async () => { - const fixture = new FixtureLauncher("wrong_identity"); - let cleanupAllowed = false; - let cleanupAttempts = 0; +it.each([ + ["wrong_identity", "protocol"], + ["import_identity_unavailable", "admission"], +] as const)( + "preserves %s and retained Ghidra cleanup until a verified retry", + async (mode, kind) => { + const fixture = new FixtureLauncher(mode); + let cleanupAllowed = false; + let cleanupAttempts = 0; + const launcher: GhidraLauncher = { + launch: async (session) => { + const launched = await fixture.launch(session); + if (!launched.ok) return launched; + return ok({ + ...launched.value, + cleanup: async () => { + cleanupAttempts += 1; + return cleanupAllowed + ? { cleaned: true as const, signaled: false } + : { + cleaned: false as const, + reason: "fixture process cleanup unconfirmed", + }; + }, + }); + }, + }; + const client = clientFor(launcher); + onTestFinished(async () => { + cleanupAllowed = true; + await client.close(); + }); + const failed = await client.start(); + expect(failed).toMatchObject({ + ok: false, + error: { kind, cleanupFailure: { cleanupIncomplete: true } }, + }); + if (failed.ok) throw new Error("Expected failed startup"); + if (mode === "import_identity_unavailable") + expect(failed.error.cause).toMatchObject({ code: mode }); + const runtime = fixture.runtimeRoots[0]; + if (runtime === undefined) + throw new Error("Fixture runtime was not captured"); + await access(runtime); + expect(cleanupAttempts).toBe(1); + expect(await client.close()).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + cleanupAllowed = true; + expect(await client.close()).toEqual(ok(null)); + expect(failed.error.cleanupFailure?.cleanupIncomplete).toBe(false); + expect(cleanupAttempts).toBe(3); + await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it("retains the identity refusal when cancellation arrives during final startup cleanup", async () => { + const fixture = new FixtureLauncher("import_identity_mismatch"); + let entered: (() => void) | undefined; + let release: (() => void) | undefined; + const cleanupStarted = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); const launcher: GhidraLauncher = { launch: async (session) => { const launched = await fixture.launch(session); @@ -2084,39 +2187,40 @@ it("preserves failed startup and retained Ghidra cleanup capabilities until a ve return ok({ ...launched.value, cleanup: async () => { - cleanupAttempts += 1; - return cleanupAllowed - ? { cleaned: true as const, signaled: false } - : { - cleaned: false as const, - reason: "fixture process cleanup unconfirmed", - }; + entered?.(); + await gate; + return { cleaned: true as const, signaled: false }; }, }); }, }; - const client = clientFor(launcher); - onTestFinished(async () => { - cleanupAllowed = true; - await client.close(); - }); - expect(await client.start()).toMatchObject({ - ok: false, - error: { kind: "protocol", cleanupFailure: { cleanupIncomplete: true } }, - }); - const runtime = fixture.runtimeRoots[0]; - if (runtime === undefined) - throw new Error("Fixture runtime was not captured"); - await access(runtime); - expect(cleanupAttempts).toBe(1); - expect(await client.close()).toMatchObject({ - ok: false, - error: { cleanupIncomplete: true }, - }); - cleanupAllowed = true; - expect(await client.close()).toEqual(ok(null)); - expect(cleanupAttempts).toBe(3); - await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" }); + const client = clientFor(launcher, { startupTimeoutMs: 10_000 }); + const controller = new AbortController(); + const started = client.start(controller.signal); + await withVerifierCleanup(async () => { + await awaitAcknowledgement( + cleanupStarted, + started, + "identity refusal cleanup", + ); + controller.abort("cancel during final cleanup"); + release?.(); + const result = await started; + if (result.ok) throw new Error("Expected the earlier identity refusal"); + expect(result.error.kind).toBe("admission"); + expect(result.error.cause).toMatchObject({ + code: "import_identity_mismatch", + }); + expect(client.isReady()).toBe(false); + await expect(access(fixture.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, [ + () => release?.(), + async () => { + await client.close(); + }, + ]); }); describe.skipIf(process.platform === "win32")( @@ -2370,42 +2474,50 @@ it.each([null, "caller stopped", new Error("caller stopped")])( }, ); -it("preserves a handshake protocol error observed before the final startup caller cancels", async () => { - const controller = new AbortController(); - let cancelled = false; - const logger = pino( - { - level: "warn", - hooks: { - logMethod(args, method) { - const fields = args[0]; - if ( - typeof fields === "object" && - fields !== null && - "method" in fields && - fields.method === "ping" && - !cancelled - ) { - cancelled = true; - controller.abort(null); - } - method.apply(this, args); +it.each([ + ["malformed", "protocol"], + ["import_identity_unavailable", "admission"], +] as const)( + "preserves %s observed before the final startup caller cancels", + async (mode, kind) => { + const controller = new AbortController(); + let cancelled = false; + const logger = pino( + { + level: "warn", + hooks: { + logMethod(args, method) { + const fields = args[0]; + if ( + typeof fields === "object" && + fields !== null && + "method" in fields && + fields.method === "ping" && + !cancelled + ) { + cancelled = true; + controller.abort(null); + } + method.apply(this, args); + }, }, }, - }, - { write: () => {} }, - ); - const launcher = new FixtureLauncher("malformed"); - const client = clientFor(launcher, { logger }); - const result = await client.start(controller.signal); - expect(cancelled).toBe(true); - if (result.ok) throw new Error("Expected handshake protocol failure"); - expect(result.error.kind).toBe("protocol"); - expect(await waitForExit(launcher.processes[0])).toBe(true); - await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ - code: "ENOENT", - }); -}); + { write: () => {} }, + ); + const launcher = new FixtureLauncher(mode); + const client = clientFor(launcher, { logger }); + const result = await client.start(controller.signal); + expect(cancelled).toBe(true); + if (result.ok) throw new Error("Expected handshake protocol failure"); + expect(result.error.kind).toBe(kind); + if (mode === "import_identity_unavailable") + expect(result.error.cause).toMatchObject({ code: mode }); + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); it("redacts the transport credential when refreshing startup diagnostics", async () => { let token = ""; diff --git a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts index 1f1e0a0033..effb563d4a 100644 --- a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts @@ -15,6 +15,7 @@ import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourc import { copyExistingGhidraProject, fingerprintGhidraProject, + GHIDRA_PROJECT_PROFILE_MISMATCH, resolveGhidraProject, } from "../../../../src/ghidra/GhidraProjectSnapshot.js"; import { parseConfig } from "../../../../src/config/parseConfig.js"; @@ -85,7 +86,9 @@ it("rejects a project whose copied content differs from the committed analysis p }), ).rejects.toMatchObject({ _tag: "AnalysisArtifactChangedError", - cause: { name: "RegularFileChangedError" }, + operation: "open_binary", + path: source, + reason: GHIDRA_PROJECT_PROFILE_MISMATCH, }); }); diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index a633278d30..329ee3c8b2 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -201,7 +201,7 @@ test("traces a procedure found only by its alias through the composed feature tr test("omits a data entry an alias names while keeping the code entry that shares it", async ({ mcp, }) => { - const { search, observe } = await aliasSession( + const { client, search, observe } = await aliasSession( mcp, "rea-kuna-alias-macho-", { @@ -234,4 +234,52 @@ test("omits a data entry an alias names while keeping the code entry that shares ), ); expect(await search({ pattern: "global_count" })).toEqual([]); + const { error } = parseMcpToolError( + await client.callTool({ + name: "procedure_address", + arguments: { procedure: "global_count" }, + }), + ); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain("address_name"); +}); + +test("reports lookup failures with recovery for the selected target, not the input schema", async ({ + mcp, +}) => { + const { client } = await aliasSession(mcp, "rea-kuna-lookup-recovery-", { + functions: VERSIONED, + }); + for (const [procedure, guidance] of [ + ["0x1335", "list_procedures"], + ["absent_name", "search_procedures"], + ["audit_transform", "exact address"], + ]) { + const response = await client.callTool({ + name: "procedure_address", + arguments: { procedure }, + }); + const { error } = parseMcpToolError(response); + expect(error.code).toBe("invalid_request"); + expect(error.recovery).toBeUndefined(); + expect(error.remediation.action).toContain(guidance); + expect(error.remediation.action).not.toContain("input schema"); + } + const assemblyError = parseMcpToolError( + await client.callTool({ + name: "procedure_assembly", + arguments: { procedure: "0x1335" }, + }), + ).error; + expect(assemblyError.remediation.action).toContain("Kuna procedure"); + expect(assemblyError.remediation.action).not.toContain("decompile"); + const missingAddress = parseMcpToolError( + await client.callTool({ + name: "xrefs", + arguments: { address: "absent_name" }, + }), + ).error; + expect(missingAddress.recovery).toBeUndefined(); + expect(missingAddress.message).toContain("no inventory entry named"); + expect(missingAddress.remediation.action).toContain("address"); }); diff --git a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts index 4e8f191faf..eaa5971802 100644 --- a/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaBatchDecompileMcp.test.ts @@ -89,6 +89,36 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item" }) ).structuredContent, ); + // The provider's version policy travels with this observation, while its + // profile commits only analysis inputs. Composed and retained views must + // preserve the same warning for agents reading those results. + const verificationWarning = + "Kuna 1.833 is not REA's verified 1.990 release"; + expect(batch.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); + expect(broken.analysis_profile?.parameters).not.toHaveProperty( + "verified_release", + ); + const summary = parseEvidence( + (await call("analyze_function", { procedure: "main", detail: "summary" })) + .structuredContent, + ); + const parent = summary.normalized_result as { parent_evidence_id: string }; + const page = parseEvidence( + ( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: parent.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }) + ).structuredContent, + ); + expect(page.limitations).toContainEqual( + expect.stringContaining(verificationWarning), + ); if (batchDecompile === undefined || getEvidenceBundle === undefined) throw new Error( "Missing batch_decompile or get_evidence_bundle contract", @@ -101,15 +131,8 @@ test("keeps each Kuna no-pseudocode observation's limitations on its batch item" expect(main).toMatchObject({ address: "main", status: "ok" }); expect(main).not.toHaveProperty("limitations"); const noPseudocode = { - status: "error", - error: { - code: "capability_unavailable", - retryable: false, - details: { - provider_id: "kuna", - operation: "procedure_pseudo_code", - }, - }, + status: "unavailable", + reason: "no_pseudocode", }; // The item keeps exactly the facts the provider reported for its own // observation, as the single-function Evidence does. diff --git a/tests/boundary/providers/kuna/kunaDirectoryProvenanceMcp.test.ts b/tests/boundary/providers/kuna/kunaDirectoryProvenanceMcp.test.ts new file mode 100644 index 0000000000..789ea7b249 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaDirectoryProvenanceMcp.test.ts @@ -0,0 +1,172 @@ +import { createHash } from "node:crypto"; +import { appendFile, readFile, unlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it as test } from "vitest"; +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { decompileDirectoryIndexSchema } from "../../../../src/contracts/decompileDirectory.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { setFakeKunaBehavior, writeFakeKuna } from "./fakeKuna.js"; +import { FUNCTIONS } from "./kunaSessionFixture.js"; + +const it = test.skipIf(process.platform === "win32"); + +it("retains each written function's producer across an export resume", async () => { + const root = await createTestTempDirectory("rea-kuna-directory-provenance-"); + const kuna = await writeFakeKuna(root, { functions: FUNCTIONS }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const run = async (cache: string, resume: boolean) => { + const provider = new KunaProvider( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, cache) }, + { platform: process.platform }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ session }); + try { + const call = async (name: string, args: Record) => { + const response = await harness.client.callTool({ + name, + arguments: args, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return parseEvidence(response.structuredContent); + }; + const opened = await harness.client.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "kuna" }, + }); + expect(opened.isError).not.toBe(true); + await call("procedure_assembly", { procedure: "main" }); + const result = await call("decompile_all", { + output_directory: output, + resume, + }); + const resultValue = result.normalized_result as { index_path: string }; + return decompileDirectoryIndexSchema.parse( + JSON.parse(await readFile(resultValue.index_path, "utf8")), + ); + } finally { + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } + }; + const partial = await run("partial", false); + const first = partial.functions.find((item) => item.address === "0x1000"); + const failed = partial.functions.find((item) => item.address === "0x1080"); + expect(first?.status).toBe("written"); + expect(first?.provider_source).toMatchObject({ + status: "reported", + source: { kind: "file-range" }, + }); + expect(failed?.status).toBe("failed"); + expect(failed?.provider_source).toMatchObject({ + status: "reported", + source: { kind: "file-range" }, + }); + if (first?.relative_path === undefined) + throw new Error("missing initial file"); + const digest = (value: Uint8Array) => + createHash("sha256").update(value).digest("hex"); + const before = digest(await readFile(join(output, first.relative_path))); + // Simulate an interrupted append after the committed failed observation. + await appendFile(join(output, "decompile-journal.jsonl"), '{"kind":"func'); + await setFakeKunaBehavior(kuna, { + functions: FUNCTIONS.map(({ name, addr }) => ({ name, addr })), + }); + const clean = await run("clean", true); + const kept = clean.functions.find((item) => item.address === "0x1000"); + const recovered = clean.functions.find((item) => item.address === "0x1080"); + expect(kept).toEqual(first); + expect(digest(await readFile(join(output, first.relative_path)))).toBe( + before, + ); + expect(recovered?.status).toBe("written"); + expect(recovered?.provider_source).toMatchObject({ + status: "reported", + source: { kind: "file-range" }, + }); + if (recovered === undefined) throw new Error("missing recovered function"); + const oldSource = first.provider_source; + const newSource = recovered.provider_source; + if ( + oldSource?.status !== "reported" || + oldSource.source.kind !== "file-range" || + newSource?.status !== "reported" || + newSource.source.kind !== "file-range" + ) + throw new Error("missing source file ranges"); + expect(newSource.source.sha256).not.toBe(oldSource.source.sha256); + const journal = ( + await readFile(join(output, "decompile-journal.jsonl"), "utf8") + ) + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + address?: string; + status?: string; + provider_source?: unknown; + }, + ); + expect(journal).toContainEqual( + expect.objectContaining({ + address: "0x1080", + status: "failed", + provider_source: failed?.provider_source, + }), + ); + // The descriptor is historical: the source cache can disappear while the + // verified output and its recorded source facts remain unchanged. + await unlink(oldSource.source.path); + const afterSourceRemoval = await run("clean", true); + expect( + afterSourceRemoval.functions.find((item) => item.address === "0x1000"), + ).toEqual(first); + expect(digest(await readFile(join(output, first.relative_path)))).toBe( + before, + ); + const outputPath = join(output, first.relative_path); + const originalBytes = await readFile(outputPath); + await writeFile(outputPath, "owned modified output"); + const conflict = await run("clean", true); + const conflictMain = conflict.functions.find( + (item) => item.address === "0x1000", + ); + expect(conflictMain).toMatchObject({ + status: "failed", + failure: { code: "output_exists" }, + }); + expect(conflictMain?.provider_source).toBeUndefined(); + expect(await readFile(outputPath, "utf8")).toBe("owned modified output"); + await writeFile(outputPath, originalBytes); + const restored = await run("clean", true); + expect(restored.functions.find((item) => item.address === "0x1000")).toEqual( + first, + ); + // A file published before a journal append can be adopted on resume, but + // matching bytes do not identify the run that originally wrote the file. + const journalPath = join(output, "decompile-journal.jsonl"); + const header = (await readFile(journalPath, "utf8")).split("\n")[0]; + await writeFile(journalPath, `${header}\n`); + const adopted = await run("clean", true); + const adoptedMain = adopted.functions.find( + (item) => item.address === "0x1000", + ); + expect(adoptedMain?.provider_source).toEqual({ + status: "unknown", + reason: "existing_file_origin_unknown", + }); + expect(digest(await readFile(join(output, first.relative_path)))).toBe( + before, + ); +}); diff --git a/tests/boundary/providers/kuna/kunaDoctor.test.ts b/tests/boundary/providers/kuna/kunaDoctor.test.ts index 8c3a5f052a..014365a61f 100644 --- a/tests/boundary/providers/kuna/kunaDoctor.test.ts +++ b/tests/boundary/providers/kuna/kunaDoctor.test.ts @@ -34,7 +34,7 @@ it("reports an unconfigured Kuna as a missing optional engine", async () => { it("publishes the caller's explicit settings once the installation validates", async () => { const root = await createTestTempDirectory("rea-kuna-doctor-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.990\n" }, }); const cache = join(root, "cache"); @@ -48,7 +48,7 @@ it("publishes the caller's explicit settings once the installation validates", a expect(inspection).toMatchObject({ configured: true, available: true, - providerVersion: "1.988", + providerVersion: "1.990", registrationEnvironment: { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cache, @@ -59,10 +59,10 @@ it("publishes the caller's explicit settings once the installation validates", a name: "installation", ok: true, detail: expect.stringMatching( - /reports Kuna 1\.988 \(sha256 [a-f0-9]{64}\)\.$/u, + /reports Kuna 1\.990 \(sha256 [a-f0-9]{64}\)\.$/u, ), remediation: null, - details: { version: "1.988", verified_version: "1.988" }, + details: { version: "1.990", verified_version: "1.990" }, }, { name: "cache", @@ -88,7 +88,7 @@ it("flags an unverified release without failing the installation", async () => { "You may continue with the selected unverified release.", ), remediation: expect.stringContaining("setting REA_KUNA_COMMAND"), - details: { verified_version: "1.988" }, + details: { verified_version: "1.990" }, }); }); diff --git a/tests/boundary/providers/kuna/kunaInstallation.test.ts b/tests/boundary/providers/kuna/kunaInstallation.test.ts index 1f66253993..801b758438 100644 --- a/tests/boundary/providers/kuna/kunaInstallation.test.ts +++ b/tests/boundary/providers/kuna/kunaInstallation.test.ts @@ -22,7 +22,7 @@ const it = test.skipIf(process.platform === "win32"); it("identifies a caller-selected kuna by version and digest", async () => { const root = await createTestTempDirectory("rea-kuna-install-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.990\n" }, }); const link = join(root, "current-kuna"); await symlink(kuna, link); @@ -37,7 +37,7 @@ it("identifies a caller-selected kuna by version and digest", async () => { value: { configuration: { command: link, cacheRoot: join(root, "cache") }, executable: await realpath(kuna), - version: "1.988", + version: "1.990", verified: true, // A `#!` launcher reports its interpreter; ownership checks must not expect the path. script: true, @@ -104,14 +104,14 @@ fs.renameSync(process.argv[1] + ".new", process.argv[1]); it("accepts another release but reports it as unverified", async () => { const root = await createTestTempDirectory("rea-kuna-unverified-"); const kuna = await writeFakeKuna(root, { - version: { stdout: "kuna 1.986\n" }, + version: { stdout: "kuna 1.989\n" }, }); const result = await inspectKunaInstallation({ REA_KUNA_COMMAND: kuna }); expect(result).toMatchObject({ ok: true, - value: { version: "1.986", verified: false }, + value: { version: "1.989", verified: false }, }); }); diff --git a/tests/boundary/providers/kuna/kunaJournalRecoveryMcp.test.ts b/tests/boundary/providers/kuna/kunaJournalRecoveryMcp.test.ts new file mode 100644 index 0000000000..87a355348a --- /dev/null +++ b/tests/boundary/providers/kuna/kunaJournalRecoveryMcp.test.ts @@ -0,0 +1,93 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it as test } from "vitest"; +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createApplicationMcpHarness } from "../../../fixtures/applicationMcpHarness.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { writeFakeKuna } from "./fakeKuna.js"; +import { FUNCTIONS } from "./kunaSessionFixture.js"; + +const it = test.skipIf(process.platform === "win32"); + +it("explains an unreadable journal without suggesting input-schema changes", async () => { + const root = await createTestTempDirectory("rea-decompile-journal-recovery-"); + const kuna = await writeFakeKuna(root, { functions: FUNCTIONS }); + const target = join(root, "program"); + const output = join(root, "output"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const provider = new KunaProvider( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: join(root, "cache") }, + { platform: process.platform }, + ); + const session = composeBinarySession( + new AnalysisProviderRegistry([provider], "kuna"), + ); + const harness = await createApplicationMcpHarness({ session }); + try { + expect( + ( + await harness.client.callTool({ + name: "open_binary", + arguments: { path: target, provider_id: "kuna" }, + }) + ).isError, + ).not.toBe(true); + const first = await harness.client.callTool({ + name: "decompile_all", + arguments: { output_directory: output, name: "main" }, + }); + expect(first.isError, JSON.stringify(first)).not.toBe(true); + const manifest = parseEvidence(first.structuredContent) + .normalized_result as { index_path: string }; + const index = JSON.parse(await readFile(manifest.index_path, "utf8")) as { + functions: Array<{ relative_path: string }>; + }; + const file = join(output, index.functions[0]!.relative_path); + const journal = join(output, "decompile-journal.jsonl"); + const lines = (await readFile(journal, "utf8")).trimEnd().split("\n"); + const record = JSON.parse(lines[1]!) as Record; + record.future_field = { version: 2 }; + lines[1] = JSON.stringify(record); + await writeFile(journal, `${lines.join("\n")}\n`); + const hash = async (path: string) => + createHash("sha256") + .update(await readFile(path)) + .digest("hex"); + const paths = [target, file, journal, manifest.index_path]; + const before = await Promise.all(paths.map(hash)); + const refused = await harness.client.callTool({ + name: "decompile_all", + arguments: { output_directory: output, name: "main", resume: true }, + }); + expect(refused.isError).toBe(true); + const result = JSON.parse( + (refused.content[0] as { text: string }).text, + ) as { + error: { + code: string; + message: string; + remediation: { action: string }; + recovery?: unknown; + }; + }; + expect(result.error).toMatchObject({ + code: "invalid_request", + message: "The decompile journal is not a compatible export.", + remediation: { + action: + "Inspect the journal and use the REA version that created it, or choose a new output directory. The journal may be damaged or from an unsupported version; existing files were left unchanged.", + }, + }); + expect(result.error.recovery).toBeUndefined(); + expect(await Promise.all(paths.map(hash))).toEqual(before); + } finally { + await harness.close(); + const closed = await provider.closeOwnedResources(); + if (!closed.ok) throw closed.error; + } +}); diff --git a/tests/boundary/providers/kuna/kunaParallelMcp.test.ts b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts new file mode 100644 index 0000000000..a4133e93a9 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaParallelMcp.test.ts @@ -0,0 +1,83 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; +import { elfNoteFixture } from "../../../../src/native/elf/ElfNotes.fixture.js"; +import { createServer } from "../../../../src/server/createServer.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { mcpTest } from "../../../support/mcp/mcpFixture.js"; +import { writeFakeKuna } from "./fakeKuna.js"; + +const test = mcpTest.skipIf(process.platform === "win32"); +const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + +for (const jobs of [1, 2]) { + test(`retains source limitations in batch and paged dossier with ${String(jobs)} worker(s)`, async ({ + mcp, + }) => { + const root = await createTestTempDirectory("rea-kuna-parallel-mcp-"); + const kuna = await writeFakeKuna(root, { + functions: [{ name: "main", addr: "0x1000" }], + }); + const target = join(root, "program"); + await writeFile(target, elfNoteFixture({ bits: 64, type: 2 })); + const session = composeBinarySession( + new AnalysisProviderRegistry( + [ + new KunaProvider( + { + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: join(root, "cache"), + REA_KUNA_JOBS: String(jobs), + }, + { platform: process.platform }, + ), + ], + "kuna", + ), + ); + const client = await mcp.connect( + createServer({ kind: "session", session }), + ); + const call = async (name: string, arguments_: Record) => { + const response = await client.callTool({ name, arguments: arguments_ }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return response.structuredContent; + }; + await call("open_binary", { path: target }); + // Assembly waits for the complete project, avoiding timing-based source selection. + await call("procedure_assembly", { procedure: "main" }); + const batch = parseEvidence( + await call("batch_decompile", { + addresses: ["0x1000"], + detail: "complete", + }), + ); + expect(batch.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + const dossier = parseEvidence( + await call("analyze_function", { procedure: "main", detail: "summary" }), + ); + const summary = dossier.normalized_result as { parent_evidence_id: string }; + const view = parseEvidence( + await call("inspect_analysis_view", { + source: { + kind: "retained-evidence", + evidence_id: summary.parent_evidence_id, + }, + view: { kind: "native", facet: "pseudocode" }, + }), + ); + expect(view.limitations.some((item) => item.includes(caveat))).toBe( + jobs > 1, + ); + await call("close_binary", {}); + }); +} diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 8907678718..a051bc6947 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -20,6 +20,7 @@ import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { AnalysisProviderRegistry } from "../../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../../src/application/binary/BinarySessionComposition.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE } from "../../../../src/kuna/KunaSessionClient.js"; import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js"; import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; @@ -182,22 +183,42 @@ it("waits for an aborted pre-session hash before retrying its late close owner", it("commits the observed Kuna release and digest to the analysis profile", async () => { const { profile, kuna } = await session("rea-kuna-profile-", { - version: { stdout: "kuna 1.988\n" }, + version: { stdout: "kuna 1.990\n" }, }); expect(profile).toMatchObject({ - provider: { id: "kuna", name: "Kuna", version: "1.988" }, + provider: { id: "kuna", name: "Kuna", version: "1.990" }, parameters: { - kuna_version: "1.988", + kuna_version: "1.990", kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), kuna_command: kuna, - verified_release: true, jobs: 1, cache_policy: "live", }, }); }); +it("reports an unverified release on observations without binding policy into the profile", async () => { + const { profile, open } = await session("rea-kuna-policy-", { + version: { stdout: "kuna 1.988\n" }, + }); + expect(profile.parameters).not.toHaveProperty("verified_release"); + expect(profile.parameters).toMatchObject({ + kuna_version: "1.988", + kuna_sha256: expect.stringMatching(/^[0-9a-f]{64}$/u), + }); + const client = open(); + const inventory = await run(client, "list_procedures"); + const warning = "Kuna 1.988 is not REA's verified 1.990 release"; + expect(inventory.limitations.join("\n")).toContain(warning); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(warning); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(warning); + await client.close(); +}); + it("lists the selected document without starting target analysis", async () => { const { kuna, target, cacheRoot, open } = await session( "rea-kuna-document-metadata-", @@ -215,6 +236,39 @@ it("lists the selected document without starting target analysis", async () => { } }); +it("qualifies only parallel project pseudocode, including dossier results", async () => { + const { open } = await session( + "rea-kuna-parallel-context-", + { delayMs: 300 }, + { REA_KUNA_JOBS: "2" }, + ); + const client = open(); + const caveat = + "Parallel Kuna project export does not propagate callee-first prototypes"; + const cold = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(cold.rawResult).toMatchObject({ source: { kind: "single-function" } }); + expect(cold.limitations.join("\n")).not.toContain(caveat); + await run(client, "procedure_assembly", { procedure: "main" }); + const warm = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(warm.rawResult).toMatchObject({ source: { kind: "project-export" } }); + expect(warm.limitations.join("\n")).toContain(caveat); + const dossier = await run(client, "analyze_function", { procedure: "main" }); + expect(dossier.limitations.join("\n")).toContain(caveat); + expect( + (dossier.result as { limitations: string[] }).limitations.join("\n"), + ).toContain(caveat); + const missing = await run(client, "procedure_pseudo_code", { + procedure: "broken", + }); + expect(missing.result).toBeNull(); + expect(missing.limitations.join("\n")).not.toContain(caveat); + await client.close(); +}); + it("answers a cold request from one function, then serves the finished export", async () => { const { kuna, cacheRoot, open } = await session("rea-kuna-cold-", { delayMs: 300, @@ -456,6 +510,10 @@ it("builds a dossier with text and call relationships, leaving unsupported facet }, pseudocode: "int main(void)\n{\n return 0;\n}", assembly: ["main: ; 0x1000", " 00001000: c3 RET"], + native_value_flow: { + available: false, + reason: KUNA_NATIVE_VALUE_FLOW_UNAVAILABLE, + }, facet_availability: { pseudocode: { available: true }, assembly: { available: true }, @@ -499,6 +557,90 @@ it("rejects oversized cached text before buffering while retaining its full expo } }); +it("can recover a standalone output overflow from a completed project", async () => { + const { open, kuna } = await session("rea-kuna-overflow-project-", { + delayMs: 3000, + decompileDelayMs: 200, + decompilePadding: KUNA_FUNCTION_TEXT_BYTES + 1, + }); + const client = open(); + const failures = await Promise.all( + ["main", "0x1000"].map((procedure) => + client.execute("procedure_pseudo_code", { procedure }), + ), + ); + for (const failure of failures) { + expect(failure.ok).toBe(false); + if (failure.ok) continue; + expect(projectAnalysisError(failure.error)).toMatchObject({ + code: "resource_constraint", + retryable: true, + remediation: { action: expect.stringContaining("reopen the target") }, + details: { + reported_limits: { output_limit_bytes: KUNA_FUNCTION_TEXT_BYTES }, + }, + }); + const invocation = projectAnalysisError(failure.error, "invocation"); + expect(invocation.remediation.action).toContain("rea function"); + expect(invocation.remediation.action).not.toContain("procedure_assembly"); + } + expect( + (await fakeKunaCalls(kuna)).filter(({ argv }) => argv[0] === "decompile"), + ).toHaveLength(1); + await run(client, "procedure_assembly", { procedure: "main" }); + const later = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(later.rawResult).toMatchObject({ source: { kind: "project-export" } }); + await client.close(); + + const failed = await session("rea-kuna-overflow-failed-project-", { + project: "exit-failure", + decompilePadding: KUNA_FUNCTION_TEXT_BYTES + 1, + }); + const failedClient = failed.open(); + expect( + (await failedClient.execute("procedure_assembly", { procedure: "main" })) + .ok, + ).toBe(false); + const overflow = await failedClient.execute("procedure_pseudo_code", { + procedure: "main", + }); + expect(overflow.ok).toBe(false); + if (!overflow.ok) + expect(projectAnalysisError(overflow.error)).toMatchObject({ + code: "resource_constraint", + retryable: false, + remediation: { action: expect.stringContaining("Use another provider") }, + }); + await failedClient.close(); + + const unproven = unprovenCleanupLauncher("decompile"); + const retained = await session( + "rea-kuna-overflow-retained-cleanup-", + { project: "hang", decompilePadding: KUNA_FUNCTION_TEXT_BYTES + 1 }, + {}, + unproven.launcher, + ); + const retainedClient = retained.open(); + try { + const result = await retainedClient.execute("procedure_pseudo_code", { + procedure: "main", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + }); + } + } finally { + unproven.release(); + expect(await retainedClient.close()).toEqual({ ok: true, value: null }); + } +}); + it("keeps answering pseudocode one function at a time when the export fails", async () => { const { open } = await session("rea-kuna-export-failed-", { project: "exit-failure", @@ -513,6 +655,7 @@ it("keeps answering pseudocode one function at a time when the export fails", as userMessage: expect.stringContaining( "not in recognized object file format", ), + diagnostics: { failed_project_records: "not_exposed" }, }, }); const pseudocode = await run(client, "procedure_pseudo_code", { @@ -520,11 +663,76 @@ it("keeps answering pseudocode one function at a time when the export fails", as }); expect(pseudocode.rawResult).toMatchObject({ source: { kind: "single-function" }, + project_export: { state: "failed", failure_records: "not_exposed" }, }); - expect(pseudocode.limitations.at(-1)).toContain("The project export failed"); + expect(pseudocode.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("The project export failed"), + "REA does not expose per-function records from a failed project export.", + ]), + ); + expect(pseudocode.limitations.join(" ")).not.toContain("still building"); + expect(pseudocode.limitations.join(" ")).toContain( + "larger project-export spans remain in the local project export", + ); + expect(pseudocode.limitations.join(" ")).not.toContain( + "a later request for the same procedure can return the export's text", + ); await client.close(); }); +it("keeps an overflow as the primary failure when cancellation arrives during final cleanup", async () => { + let enteredCleanup!: () => void; + let releaseCleanup!: () => void; + const entered = new Promise((resolve) => (enteredCleanup = resolve)); + const released = new Promise((resolve) => (releaseCleanup = resolve)); + const launcher: KunaProcessLauncher = async (options) => { + const spawned = await spawnOwnedProviderProcess(options); + if (options.arguments[0] !== "decompile") return spawned; + return { + ...spawned, + cleanup: async () => { + const result = await cleanupOwnedProcessGroup(spawned.ownership); + enteredCleanup(); + await released; + return result; + }, + }; + }; + const fixture = await session( + "rea-kuna-overflow-cancel-finalization-", + { project: "hang", decompilePadding: KUNA_FUNCTION_TEXT_BYTES + 1 }, + {}, + launcher, + ); + const client = fixture.open(); + const caller = new AbortController(); + const pending = client.execute( + "procedure_pseudo_code", + { procedure: "main" }, + { signal: caller.signal }, + ); + try { + await entered; + caller.abort("cancel during overflow cleanup"); + releaseCleanup(); + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + details: { + reported_limits: { output_limit_bytes: KUNA_FUNCTION_TEXT_BYTES }, + }, + }); + } finally { + releaseCleanup(); + caller.abort(); + await pending; + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + it("stops waiting for the export when the caller cancels, without abandoning it", async () => { const { open } = await session("rea-kuna-wait-cancel-", { delayMs: 1500 }); const client = open(); @@ -846,7 +1054,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, diff --git a/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts b/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts index ffbaae6f48..ee8e64709f 100644 --- a/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts +++ b/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts @@ -182,3 +182,37 @@ it("preserves the original failed export when staging cleanup also fails", async await cache.close(); } }); + +it("retains failed export cleanup ownership while reporting fallback record availability", async () => { + const { cacheRoot, open } = await session("rea-kuna-failed-export-cleanup-", { + project: "exit-failure", + }); + const client = open(); + fault.root = cacheRoot; + fault.blocked = true; + try { + const first = await client.execute("procedure_assembly", { + procedure: "main", + }); + expect(first).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + if (first.ok) throw new Error("Expected retained cleanup failure"); + expect(first.error.cleanupResources.length).toBeGreaterThan(0); + const pseudo = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(pseudo.rawResult).toMatchObject({ + source: { kind: "single-function" }, + project_export: { state: "failed", failure_records: "not_exposed" }, + }); + expect((await client.close()).ok).toBe(false); + fault.blocked = false; + expect(await client.close()).toEqual({ ok: true, value: null }); + expect(first.error.cleanupIncomplete).toBe(false); + } finally { + fault.blocked = false; + await client.close(); + } +}); diff --git a/tests/boundary/providers/native/nativeCallObservation.test.ts b/tests/boundary/providers/native/nativeCallObservation.test.ts index beea50d320..cdb2897256 100644 --- a/tests/boundary/providers/native/nativeCallObservation.test.ts +++ b/tests/boundary/providers/native/nativeCallObservation.test.ts @@ -13,6 +13,10 @@ import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js" import { EvidenceIntegrityError } from "../../../../src/domain/evidenceErrors.js"; import { nativeCallPartialObservationSchema } from "../../../../src/domain/native/nativeCallPartialObservation.js"; import { nativeCallObservationResultSchema } from "../../../../src/domain/native/nativeCallObservation.js"; +import { + NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION, + NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION, +} from "../../../../src/native/NativeCallObservation.js"; import { err, ok, type Result } from "../../../../src/domain/result.js"; import type { NativeCallTrace, @@ -216,6 +220,80 @@ describe("observe_native_calls projection: projects completed observations", () }); }); +describe("observe_native_calls projection: keeps locations within the metadata budget", () => { + it("projects every breakpoint location when the aggregate metadata budget allows it", async () => { + const locations = Array.from({ length: 65 }, (_, index) => ({ + ...LOCATION, + load_address: `0x${(0x100000928 + index * 4).toString(16)}`, + file_address: `0x${(0x100000928 + index * 4).toString(16)}`, + })); + const observed = await observe( + new FixtureTracer( + ok({ + debugger: { + path: "/usr/bin/lldb", + sha256: "a".repeat(64), + version: null, + }, + run: run({ + breakpoints: [ + { index: 0, location_count: locations.length, locations }, + { index: 1, location_count: 1, locations: [LOCATION] }, + ], + }), + stdout: { text: "", bytes: 0, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, + terminated: true, + }), + ), + { breakpoints: BREAKPOINTS }, + ); + if (!observed.ok) throw observed.error; + const result = nativeCallObservationResultSchema.parse( + observed.value.result, + ); + expect(result.breakpoints[0]?.location_count).toBe(65); + expect(result.breakpoints[0]?.locations).toHaveLength(65); + expect(result.coverage).toEqual({ + status: "complete", + event_limit_reached: false, + resource_limit_reached: false, + unresolved_breakpoints: [], + }); + }); + + it("uses a generic warning when locations are missing without a budget signal", async () => { + const observed = await observe( + new FixtureTracer( + ok({ + debugger: { + path: "/usr/bin/lldb", + sha256: "a".repeat(64), + version: null, + }, + run: run({ + breakpoints: [ + { index: 0, location_count: 2, locations: [LOCATION] }, + { index: 1, location_count: 1, locations: [LOCATION] }, + ], + }), + stdout: { text: "", bytes: 0, truncated: false, complete: true }, + stderr: { text: "", bytes: 0, truncated: false, complete: true }, + terminated: true, + }), + ), + { breakpoints: BREAKPOINTS }, + ); + if (!observed.ok) throw observed.error; + const result = nativeCallObservationResultSchema.parse( + observed.value.result, + ); + expect(result.limitations).toContain( + NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION, + ); + }); +}); + describe("observe_native_calls projection: reports partial observation coverage", () => { it("retains normal exit and location counts when breakpoint metadata is bounded", async () => { const observed = await observe( @@ -259,7 +337,10 @@ describe("observe_native_calls projection: reports partial observation coverage" unresolved_breakpoints: [], }); expect(result.limitations).toContain( - "Resolved breakpoint locations exceeded the aggregate 8388608-byte (8 MiB) metadata budget; omitted locations are counted in location_count. This metadata limit does not change the process outcome. This release has no runtime override.", + NATIVE_CALL_BREAKPOINT_LOCATION_BUDGET_LIMITATION, + ); + expect(result.limitations).not.toContain( + NATIVE_CALL_BREAKPOINT_LOCATION_LIST_INCOMPLETE_LIMITATION, ); expect(result.limitations.join(" ")).not.toContain("process was killed"); }); diff --git a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts index 2af75bb630..76fda7c2b4 100644 --- a/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts +++ b/tests/boundary/providers/native/nativeDmgArtifactReader.test.ts @@ -1246,11 +1246,16 @@ describe("native DMG verification resource availability", () => { exitCode: 1, }; - it("retries the explicit transient refusal before attachment and retains both attempts", async () => { + it("settles an explicit transient refusal within the verification deadline before attaching", async () => { const { host, state } = createOmittedInventoryHost(); state.inventoryMode = "listed"; state.detachAttempts = 4; - const delays = vi.fn(async () => {}); + let now = 0; + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + onTestFinished(() => clock.mockRestore()); + const delays = vi.fn(async (milliseconds: number) => { + now += milliseconds; + }); const timeouts: number[] = []; let attempts = 0; const reader = await createAttachedReader( @@ -1266,36 +1271,45 @@ describe("native DMG verification resource availability", () => { if (options === undefined) throw new Error("missing verify deadline"); timeouts.push(options.timeoutMs); - if (attempts === 1) return transientFailure; + if (attempts < 5) return transientFailure; } return host.run(arguments_, signal, options); }, }, ); onTestFinished(() => reader.close()); - expect(attempts).toBe(2); - expect(delays.mock.calls).toEqual([[250]]); + expect(attempts).toBe(5); + expect(now).toBeGreaterThan(750); expect(timeouts[0]).toBeLessThanOrEqual(120_000); - expect(timeouts[1]).toBeLessThanOrEqual(timeouts[0]!); - expect(reader.provenance().slice(0, 2)).toMatchObject([ + expect(timeouts.at(-1)).toBeLessThan(timeouts[0]!); + expect( + reader + .provenance() + .slice(0, 5) + .map(({ exit_code }) => exit_code), + ).toEqual([1, 1, 1, 1, 0]); + expect(reader.provenance().slice(0, 1)).toMatchObject([ { arguments: ["verify", "/tmp/image.dmg"], exit_code: 1, effects: ["read"], }, - { - arguments: ["verify", "/tmp/image.dmg"], - exit_code: 0, - effects: ["read"], - }, ]); await reader.close(); expect(state.attached).toBe(false); }); it("bounds repeated transient refusals and never attaches an unverified image", async () => { - const run = vi.fn(async () => transientFailure); - const delay = vi.fn(async () => {}); + let now = 0; + const clock = vi.spyOn(performance, "now").mockImplementation(() => now); + onTestFinished(() => clock.mockRestore()); + const run = vi.fn(async () => { + now += 40_000; + return transientFailure; + }); + const delay = vi.fn(async (milliseconds: number) => { + now += milliseconds; + }); const reader = new NativeDmgArtifactReader("/tmp/image.dmg", process.env, { run, delay, @@ -1306,7 +1320,8 @@ describe("native DMG verification resource availability", () => { }); await reader.close(); expect(run.mock.calls).toHaveLength(3); - expect(delay.mock.calls).toEqual([[250], [500]]); + expect(now).toBeGreaterThanOrEqual(120_000); + expect(delay.mock.calls).toHaveLength(2); expect(reader.provenance().map(({ exit_code }) => exit_code)).toEqual([ 1, 1, 1, ]); diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index 7d42727f2e..eeb8efc235 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -269,7 +269,9 @@ it("retains distinct same-family FAT slices for signature coverage", async () => }); }); -it.each([42, 0x81000002])( +// 0xc1000002 carries an arm64e capability flag outside the versioned +// pointer-authentication ABI, so codesign may name it differently. +it.each([42, 0xc1000002])( "reports unknown signature coverage for unmapped FAT subtype %i", async (subtype) => { const path = join( @@ -312,6 +314,114 @@ it.each([42, 0x81000002])( }, ); +const ARM64E_V0 = { type: CPU.arm64.type, subtype: 0x80000002 } as const; +const ARM64E_V1 = { type: CPU.arm64.type, subtype: 0x81000002 } as const; + +it.each([ + ["arm64e.v1 beside x86_64", [ARM64E_V1, CPU.x86_64], false], + ["arm64e.v1 before arm64e", [ARM64E_V1, ARM64E_V0], true], + ["arm64e before arm64e.v1", [ARM64E_V0, ARM64E_V1], true], +] as const)( + "inspects the selected %s slice through codesign's arm64e selector", + async (_label, cpus, laterSliceShadowed) => { + const path = join( + await createTestTempDirectory("rea-native-arm64e-abi-"), + "fixture", + ); + await writeFile( + path, + fatImage(cpus.map((cpu) => ({ cpu, bytes: machoImage({ cpu }) }))), + ); + const header = parseExecutableHeader(await readFile(path), "arm64"); + if (!header.ok || header.value.format !== "mach-o-universal") + throw new Error("expected universal fixture"); + // Admission selects the first slice; codesign resolves -a arm64e to the + // first arm64e slice in table order, whatever its ABI version. + expect(header.value.universalSlice.offset).toBe( + header.value.universalSlices[0]?.offset, + ); + const replay = signatureRunner({ + signed: await fixture("codesign.txt"), + unsigned: await fixture("dyld-inventory/codesign-unsigned.txt"), + entitlements: await fixture("entitlements.xml"), + unsignedHeader: true, + }); + const calls: (readonly string[])[] = []; + const runner: NativeCommandRunner = { + run(tool, args, options) { + calls.push(args); + return replay.run(tool, args, options); + }, + }; + const client = new NativeMacOSProvider({}, runner, "darwin").createClient({ + ...(await nativeMachoTargetForFile(path)), + ...header.value, + }); + const result = await client.execute("inspect_signature", {}); + if (!result.ok) throw result.error; + expect(calls[0]).toEqual(expect.arrayContaining(["-a", "arm64e"])); + // A later slice with the same selector name has no exact selector. + const unknown = expect.stringContaining("signature states are unknown"); + expect( + inspectSignatureSchema.parse(result.value.result).limitations, + ).toEqual( + laterSliceShadowed + ? expect.arrayContaining([unknown]) + : expect.not.arrayContaining([unknown]), + ); + }, +); + +it("keeps an arm64e.v1 slice unknown behind an unmapped arm64e variant", async () => { + const path = join( + await createTestTempDirectory("rea-native-arm64e-shadow-"), + "fixture", + ); + const variant = { type: CPU.arm64.type, subtype: 0xc1000002 }; + await writeFile( + path, + fatImage( + [variant, ARM64E_V1, CPU.x86_64].map((cpu) => ({ + cpu, + bytes: machoImage({ cpu }), + })), + ), + ); + const header = parseExecutableHeader(await readFile(path), "x64"); + if (!header.ok || header.value.format !== "mach-o-universal") + throw new Error("expected universal fixture"); + expect(header.value.universalSlice.offset).toBe( + header.value.universalSlices[2]?.offset, + ); + const replay = signatureRunner({ + signed: await fixture("codesign.txt"), + unsigned: await fixture("dyld-inventory/codesign-unsigned.txt"), + entitlements: await fixture("entitlements.xml"), + unsignedHeader: true, + }); + const calls: (readonly string[])[] = []; + const runner: NativeCommandRunner = { + run(tool, args, options) { + calls.push(args); + return replay.run(tool, args, options); + }, + }; + const client = new NativeMacOSProvider({}, runner, "darwin").createClient({ + ...(await nativeMachoTargetForFile(path)), + ...header.value, + }); + const result = await client.execute("inspect_signature", {}); + if (!result.ok) throw result.error; + // codesign -a arm64e may resolve to the earlier variant, so neither arm64e + // slice has an exact selector. + expect(calls.filter((args) => args.includes("arm64e"))).toEqual([]); + expect(inspectSignatureSchema.parse(result.value.result).limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("signature states are unknown"), + ]), + ); +}); + it.each(["x64", "arm64"] as const)( "inspects an x86_64 LIB64 slice when %s is selected", async (host) => { diff --git a/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts new file mode 100644 index 0000000000..a2de197863 --- /dev/null +++ b/tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts @@ -0,0 +1,138 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { expect, it, onTestFinished } from "vitest"; +import { z } from "zod"; + +import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; +import { AnalysisInputError } from "../../../../src/domain/analysisErrorCore.js"; +import { createNativeUiHelperRuntime } from "../../../../src/native/NativeUiHelperRuntime.js"; +import { observeNativeUi } from "../../../../src/native/NativeUiObservation.js"; +import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../../src/process/ProviderProcess.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const scopeSchema = z.object({ + pid: z.number().int().positive(), + window_id: z.number().int().positive(), +}); + +// Ownership starts with the launch: the app inherits the launcher's run token, so +// the sweep finds it with or without a ready record. /usr/bin/open's own token is +// withheld, so its retained handle must stop it before the sweep can succeed. +const launch = async (bundle: string, name: string) => { + const coordinates = join(bundle, "..", `${name}.json`); + await writeFile(coordinates, ""); + const owned = await spawnOwnedProviderProcess({ + command: "/usr/bin/open", + arguments: ["-W", "-n", "-g", "--stdout", coordinates, bundle], + runId: randomUUID(), + sweepTokenOwnedProcesses: true, + }); + const launcher = new ProviderProcessSupervisor( + { process: owned.process, ownsProcessLifetime: true }, + { maxDiagnosticBytes: { stdout: 0, stderr: 64 * 1024 } }, + ); + onTestFinished(async () => { + const stopped = await launcher.stop(); + const swept = await cleanupOwnedProcessGroup(owned.ownership); + expect(stopped.status).not.toBe("incomplete"); + expect(swept).toMatchObject({ cleaned: true }); + }); + const deadline = performance.now() + 30_000; + for (;;) { + const [line] = (await readFile(coordinates, "utf8")).split("\n"); + if (line !== undefined && line.length > 0) + return scopeSchema.parse(JSON.parse(line)); + if (performance.now() >= deadline) + throw new Error("The off-screen fixture never reported its window"); + await delay(50); + } +}; + +// The fixture window is real and never ordered in; the helper is compiled and run +// by the production runtime. Capture is off for the raw helper reads, so these +// facts need no Accessibility or Screen Recording permission. +it.skipIf(process.platform !== "darwin")( + "selects a window that exists off screen and still refuses absent and foreign windows", + { timeout: 240_000 }, + async () => { + const root = await createTestTempDirectory("rea-native-ui-offscreen-"); + const bundle = join(root, "Fixture.app"); + const contents = join(bundle, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + `CFBundleExecutablefixtureCFBundleIdentifierio.rea.source-owned-offscreen-fixtureCFBundleNameREA Offscreen FixtureCFBundlePackageTypeAPPL`, + ); + const executable = join(contents, "MacOS", "fixture"); + await promisify(execFile)("/usr/bin/xcrun", [ + "swiftc", + "-module-cache-path", + join(root, "modules"), + resolve("tests/conformance/native/ui-offscreen.swift"), + "-o", + executable, + ]); + const selected = await launch(bundle, "selected"); + const foreign = await launch(bundle, "foreign"); + const target = await parseBinaryTarget(executable); + if (!target.ok) throw target.error; + + const runtime = createNativeUiHelperRuntime(process.env); + onTestFinished(() => runtime.close()); + const read = (windowId: number) => + runtime.invoke({ + pid: selected.pid, + window_id: windowId, + executable: target.value.path, + sha256: target.value.sha256, + screenshot: false, + accessibility: false, + max_nodes: 1, + }); + + expect(await read(selected.window_id)).toMatchObject({ + ok: true, + result: { window: { pid: selected.pid, window_id: selected.window_id } }, + }); + for (const windowId of [foreign.window_id, 2_147_483_000]) + expect(await read(windowId)).toMatchObject({ + ok: false, + code: "window-mismatch", + }); + + // Through the shared observation, the window is no longer called absent. Its + // outcome depends on the host's permissions, which this test does not select. + const options = { environment: process.env, runtime }; + const observed = await observeNativeUi( + target.value, + "observe_native_ui", + selected, + options, + ); + if (!observed.ok) + expect(JSON.stringify(observed.error)).not.toContain("window-mismatch"); + + // The fact the verifier's changed-bytes check reads. + const changed = await observeNativeUi( + { ...target.value, sha256: "0".repeat(64) }, + "observe_native_ui", + selected, + options, + ); + expect(changed.ok).toBe(false); + if (changed.ok) return; + expect(changed.error).toBeInstanceOf(AnalysisInputError); + expect((changed.error as AnalysisInputError).issues?.[0]).toMatchObject({ + path: ["pid"], + message: expect.stringMatching(/^target-mismatch:/u), + }); + }, +); diff --git a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts index 5cfd3a6f8d..09d8710062 100644 --- a/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseSnapshot.test.ts @@ -206,6 +206,20 @@ it.runIf(process.platform !== "win32")( }, ); +it.runIf(process.platform !== "win32")( + "does not offer a resolved-file retry for a derived WAL symlink", + async () => { + const { database, workspace, capture } = await fixture(true); + const target = workspace.path("wal-target"); + await rename(database.walPath, target); + await symlink(target, database.walPath); + await expect(capture()).rejects.toMatchObject({ + operationCause: { reason: "path", resolvedPath: undefined }, + completedMembers: [{ role: "database", path: database.path }], + }); + }, +); + it.each(["activate", "replace", "remove"])( "rejects a cold journal changed during database acquisition: %s", async (change) => { diff --git a/tests/composition/analysis-sessions/binarySession.test.ts b/tests/composition/analysis-sessions/binarySession.test.ts index ca4b34d3e8..9c3922f457 100644 --- a/tests/composition/analysis-sessions/binarySession.test.ts +++ b/tests/composition/analysis-sessions/binarySession.test.ts @@ -309,7 +309,11 @@ it("reports an external cleanup blocker until its exact owner confirms release", (_value, error) => error, ); expect(await session.status()).toMatchObject({ cleanup_pending: true }); - expect(await session.open(target)).toEqual(err(failure)); + expect(await session.open(target)).toEqual({ + ok: false, + error: failure, + selection: { kind: "none" }, + }); await session.close({ cleanupRetry: { operation: "close_binary" } }); expect(released).toBe(false); expect(await session.status()).toMatchObject({ cleanup_pending: true }); @@ -1862,7 +1866,11 @@ describe("no replacement start", () => { }); expect((await session.open(first)).ok).toBe(true); - expect(await session.open(second)).toEqual(err(cleanupError)); + expect(await session.open(second)).toEqual({ + ok: false, + error: cleanupError, + selection: { kind: "none" }, + }); expect(created).toBe(1); expect(await session.status()).toMatchObject({ open: false }); }); @@ -1921,7 +1929,11 @@ it("blocks analysis rebinding until the external writer cleanup settles", async ); expect(result).toEqual(resultOk(err(failure))); expect(session.activeTarget()).toBeUndefined(); - expect(await session.open(target)).toEqual(err(failure)); + expect(await session.open(target)).toEqual({ + ok: false, + error: failure, + selection: { kind: "none" }, + }); const anotherMutation = vi.fn(async () => resultOk("unrelated command")); expect( await session.withAdmittedExternalMutation( diff --git a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts index 9ec50f42df..aa38d49367 100644 --- a/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts +++ b/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts @@ -128,7 +128,11 @@ it.each(["close", "switch", "open after close"] as const)( transition === "close" ? session.close() : session.open(second); const initial = transition === "switch" ? await attempt() : await session.close(); - expect(initial).toEqual(err(cleanupError)); + expect(initial).toEqual( + transition === "switch" + ? { ...err(cleanupError), selection: { kind: "none" } } + : err(cleanupError), + ); expect(session.activeTarget()).toBeUndefined(); expect(await session.status()).toMatchObject({ open: false, @@ -149,7 +153,11 @@ it.each(["close", "switch", "open after close"] as const)( }); await access(runtime); - expect(await attempt()).toEqual(err(cleanupError)); + expect(await attempt()).toEqual( + transition === "close" + ? err(cleanupError) + : { ...err(cleanupError), selection: { kind: "none" } }, + ); expect(await session.status()).toMatchObject({ analysis_run: { run_id: fixture.runIds[0] }, }); diff --git a/tests/composition/analysis-sessions/providerBinding.test.ts b/tests/composition/analysis-sessions/providerBinding.test.ts index efd8bd7808..5ddfdfe958 100644 --- a/tests/composition/analysis-sessions/providerBinding.test.ts +++ b/tests/composition/analysis-sessions/providerBinding.test.ts @@ -175,6 +175,7 @@ describe("target-bound provider routing", () => { expect(cancelled).toEqual({ ok: false, error: new AnalysisCancelledError("open_binary", { cause: reason }), + selection: { kind: "binary", path: target, sha256: expect.any(String) }, }); if (!cancelled.ok) expect(cancelled.error.cause).toBe(reason); expect(alpha.clients[0]?.closed).toBe(false); diff --git a/tests/composition/directAnalysisXrefs.test.ts b/tests/composition/directAnalysisXrefs.test.ts index f32601ffe8..8637c24bc3 100644 --- a/tests/composition/directAnalysisXrefs.test.ts +++ b/tests/composition/directAnalysisXrefs.test.ts @@ -211,8 +211,12 @@ describe("direct CLI cross-reference name resolution", () => { ).then(projectCliAnalysisResult); expect(missing).toMatchObject({ code: "invalid_request", + remediation: { + action: expect.stringContaining("use different address coordinates"), + }, details: { operation: "xrefs", issues: [{ path: ["address"] }] }, }); + expect(missing).not.toHaveProperty("recovery"); expect(JSON.stringify(missing)).toContain("missing_symbol"); expect(provider.closed()).toBe(2); }); @@ -228,7 +232,13 @@ describe("direct CLI cross-reference name resolution", () => { address: "ambiguous", }, ).then(projectCliAnalysisResult); - expect(result).toMatchObject({ code: "invalid_request" }); + expect(result).toMatchObject({ + code: "invalid_request", + remediation: { + action: expect.stringContaining("Select an exact address"), + }, + }); + expect(result).not.toHaveProperty("recovery"); expect(JSON.stringify(result)).toContain("0x1000"); expect(JSON.stringify(result)).toContain("0x2000"); expect(provider.closed()).toBe(1); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index cc5987bcb6..4c353e2818 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -73,7 +73,7 @@ describe("reverse engineering command services", () => { exit_code: 0, output_truncated: false, }); - expect(result.value.provider.id).toBe("gnu.objdump"); + expect(result.value.provider.id).toBe("objdump.command"); expect(result.value.subject?.local_path).toBe(path); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/tests/composition/reverseEngineeringAdmission.test.ts b/tests/composition/reverseEngineeringAdmission.test.ts index 6c7511f7c0..3ca64cf576 100644 --- a/tests/composition/reverseEngineeringAdmission.test.ts +++ b/tests/composition/reverseEngineeringAdmission.test.ts @@ -292,7 +292,7 @@ it.each([ expect(result.error.cause).toBe(code === "ABORT_ERR" ? reason : failure); if (code === "EACCES") { expect(result.error).toMatchObject({ - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", diff --git a/tests/composition/reverseProviderLaunchErrors.test.ts b/tests/composition/reverseProviderLaunchErrors.test.ts index 5cd951a8ba..8513f9b08e 100644 --- a/tests/composition/reverseProviderLaunchErrors.test.ts +++ b/tests/composition/reverseProviderLaunchErrors.test.ts @@ -39,7 +39,7 @@ it.each(["ENOENT", "EACCES", "EPERM", "ENOTDIR"] as const)( code: "capability_unavailable", retryable: false, details: { - provider_id: "objdump", + provider_id: "objdump.command", diagnostics: { boundary: "provider-launch", command, diff --git a/tests/conformance/artifactComparisonDocs.test.ts b/tests/conformance/artifactComparisonDocs.test.ts new file mode 100644 index 0000000000..cf9a93cf7c --- /dev/null +++ b/tests/conformance/artifactComparisonDocs.test.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { expect, it } from "vitest"; +import { + artifactFormatMetadataSchema, + COMPARED_FORMAT_METADATA_KINDS, +} from "../../src/domain/artifactFormatMetadata.js"; + +const FORMAT_METADATA_KINDS = new Set( + artifactFormatMetadataSchema.options.map((option) => option.shape.kind.value), +); +// Markers, not the paragraph's wording, delimit the contract's compared kinds. +const BEGIN = ""; +const END = ""; + +it("names exactly the format metadata kinds that artifact comparison compares", async () => { + const contracts = await readFile(resolve("docs/mcp-contracts.md"), "utf8"); + const start = contracts.indexOf(BEGIN); + const end = contracts.indexOf(END); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + expect(contracts.indexOf(BEGIN, start + 1)).toBe(-1); + expect(contracts.indexOf(END, end + 1)).toBe(-1); + const named = [ + ...contracts.slice(start + BEGIN.length, end).matchAll(/`([^`]+)`/gu), + ] + .map((match) => match[1] ?? "") + .filter((token) => FORMAT_METADATA_KINDS.has(token)); + expect(new Set(named)).toEqual(new Set(COMPARED_FORMAT_METADATA_KINDS)); +}); diff --git a/tests/conformance/c/ppc.c b/tests/conformance/c/ppc.c index 3f62ca57e6..8959fde633 100644 --- a/tests/conformance/c/ppc.c +++ b/tests/conformance/c/ppc.c @@ -1,6 +1,8 @@ /* Freestanding, source-owned static-analysis fixture. Never executed by REA. */ volatile unsigned int rea_ppc_global = 7; const char rea_ppc_marker[] = "rea-ppc-source-owned-fixture"; +struct ReaPpcBits { unsigned int first : 3; unsigned int second : 5; }; +volatile struct ReaPpcBits rea_ppc_bits = {1, 2}; __attribute__((noinline)) unsigned int rea_ppc_leaf(unsigned int value) { diff --git a/tests/conformance/contracts/targetContentProvenance.test.ts b/tests/conformance/contracts/targetContentProvenance.test.ts index 3041892013..58ec19d16d 100644 --- a/tests/conformance/contracts/targetContentProvenance.test.ts +++ b/tests/conformance/contracts/targetContentProvenance.test.ts @@ -1,3 +1,6 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + import { describe, expect, it } from "vitest"; import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js"; @@ -28,6 +31,17 @@ describe("target-content provenance audit", () => { ]); }); + it("documents exactly the audited content-free exceptions", async () => { + const guide = await readFile(resolve("docs/mcp-contracts.md"), "utf8"); + const exceptions = /on by default: ([^.]*?) omit it\./su.exec(guide)?.[1]; + expect(exceptions).toBeDefined(); + expect( + [...(exceptions ?? "").matchAll(/`([a-z_]+)`/gu)] + .map(([, name]) => name) + .sort(), + ).toEqual([...TARGET_CONTENT_FREE_TOOLS].sort()); + }); + it.each([ "procedure_pseudo_code", "list_strings", diff --git a/tests/conformance/ghidra/ReaCrossBlockFixture.java b/tests/conformance/ghidra/ReaCrossBlockFixture.java new file mode 100644 index 0000000000..493acc7b39 --- /dev/null +++ b/tests/conformance/ghidra/ReaCrossBlockFixture.java @@ -0,0 +1,34 @@ +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.mem.Memory; +import java.io.ByteArrayInputStream; + +/** Create owned test memory in a disposable project; the input file is unchanged. */ +public class ReaCrossBlockFixture extends GhidraScript { + @Override + public void run() throws Exception { + Memory memory = currentProgram.getMemory(); + Address base = toAddr("60000000"); + int transaction = currentProgram.startTransaction("REA search-boundary fixture"); + try { + memory.createInitializedBlock("cross_first", base, + new ByteArrayInputStream(new byte[] {(byte) 0xde, (byte) 0xad}), 2, monitor, false); + memory.createInitializedBlock("cross_second", base.add(2), + new ByteArrayInputStream(new byte[] {0x11, 0x22}), 2, monitor, false); + memory.createInitializedBlock("cross_third", base.add(4), + new ByteArrayInputStream(new byte[] {(byte) 0xbe, (byte) 0xef}), 2, monitor, false); + Address gap = base.add(0x100); + memory.createInitializedBlock("gap_first", gap, + new ByteArrayInputStream(new byte[] {(byte) 0xde, (byte) 0xad}), 2, monitor, false); + memory.createUninitializedBlock("gap_middle", gap.add(2), 2, false); + memory.createInitializedBlock("gap_second", gap.add(4), + new ByteArrayInputStream(new byte[] {(byte) 0xbe, (byte) 0xef}), 2, monitor, false); + memory.createInitializedBlock("overlay_test", base, + new ByteArrayInputStream(new byte[] {(byte) 0xde, (byte) 0xad, 0x11, 0x22, (byte) 0xbe, (byte) 0xef}), 6, monitor, true); + currentProgram.endTransaction(transaction, true); + } catch (Exception failure) { + currentProgram.endTransaction(transaction, false); + throw failure; + } + } +} diff --git a/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java b/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java index 9c3bf4e1cf..e184f989e6 100644 --- a/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java +++ b/tests/conformance/ghidra/ReaFileOffsetSourceProbe.java @@ -49,7 +49,8 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript { var entry = address.add(0x910); currentProgram.getFunctionManager().createFunction("foreign_only", entry, new AddressSet(entry), SourceType.USER_DEFINED); - rejected(resolve, bridge, "no loaded source mapping", "file-offset:0x910", new JsonObject()); + rejectedCode(resolve, bridge, "file_offset_unmapped", "no loaded source mapping", + "file-offset:0x910", new JsonObject()); rejectedCode(reverse, bridge, "file_mapping_absent", "has no authoritative mapping to the admitted target file", reverseInput("0x140100910")); @@ -100,7 +101,8 @@ public final class ReaFileOffsetSourceProbe extends HeadlessScript { var duplicate = memory.createFileBytes("duplicate.bin", 0, bytes.length, new ByteArrayInputStream(bytes), monitor); memory.createInitializedBlock("duplicate", toAddr(0x140201000L), duplicate, 0x200, 0x200, false); - rejected(resolve, bridge, "multiple loaded mappings", "file-offset:0x210", new JsonObject()); + rejectedCode(resolve, bridge, "file_offset_ambiguous", "multiple loaded mappings", + "file-offset:0x210", new JsonObject()); println("REA_FILE_OFFSET_SOURCE_OK foreign/partial/uninitialized/unmapped rejected; original round trip; duplicate ambiguity; injected backend failure context retained"); } diff --git a/tests/conformance/ghidra/ReaNoReturnProbe.java b/tests/conformance/ghidra/ReaNoReturnProbe.java index 19f98f346a..c5ca14817f 100644 --- a/tests/conformance/ghidra/ReaNoReturnProbe.java +++ b/tests/conformance/ghidra/ReaNoReturnProbe.java @@ -123,7 +123,8 @@ public class ReaNoReturnProbe extends GhidraScript { query.addProperty("direction", "outgoing"); Method read = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class); read.setAccessible(true); - JsonObject output = (JsonObject) read.invoke(bridge, query); + // procedureReferences returns a function observation envelope. + JsonObject output = ((JsonObject) read.invoke(bridge, query)).getAsJsonObject("value"); require("interior_reference_keeps_known_procedure", output.getAsJsonArray("references").asList().stream().anyMatch( edge -> !edge.getAsJsonObject().get("target_procedure").isJsonNull() && edge.getAsJsonObject().getAsJsonObject("target_procedure").get("name").getAsString().equals(target.getName()))); @@ -185,13 +186,13 @@ public class ReaNoReturnProbe extends GhidraScript { query.addProperty("direction", "outgoing"); Method edges = bridge.getClass().getDeclaredMethod("procedureReferences", JsonObject.class); edges.setAccessible(true); - JsonObject known = (JsonObject) edges.invoke(bridge, query); + JsonObject known = ((JsonObject) edges.invoke(bridge, query)).getAsJsonObject("value"); require("non_call_known_reference_is_not_unresolved", known.getAsJsonArray("unresolved_calls").isEmpty()); references.delete(added); Address unknown = currentProgram.getMemory().getMaxAddress().add(0x1000); added = references.addMemoryReference(source.getAddress(), unknown, RefType.UNCONDITIONAL_CALL, SourceType.USER_DEFINED, -1); - JsonObject unresolved = (JsonObject) edges.invoke(bridge, query); + JsonObject unresolved = ((JsonObject) edges.invoke(bridge, query)).getAsJsonObject("value"); require("non_call_unknown_reference_keeps_site", unresolved.getAsJsonArray("unresolved_calls").size() == 1); } finally { references.delete(added); @@ -253,14 +254,18 @@ public class ReaNoReturnProbe extends GhidraScript { !adjacentCaller.getBody().contains(adjacentEntry)); GhidraScript bridge = script("ReaGhidraBridge.java", new File(directory, "ReaGhidraBridge.java")); - Method warnings = bridge.getClass().getDeclaredMethod("noReturnCallLimitations", Iterable.class); + Method warnings = bridge.getClass().getDeclaredMethod( + "functionBoundaryLimitations", Function.class, Iterable.class); warnings.setAccessible(true); - Object reported = warnings.invoke(bridge, currentProgram.getListing().getInstructions(function("rea_unknown").getBody(), true)); + Object reported = warnings.invoke(bridge, function("rea_unknown"), + currentProgram.getListing().getInstructions(function("rea_unknown").getBody(), true)); require("unknown_boundary_named", reported instanceof List values && values.stream().anyMatch(value -> value.toString().contains("rea_unknown_stop") && value.toString().contains("fallthrough"))); - Object healthy = warnings.invoke(bridge, currentProgram.getListing().getInstructions(function("rea_tls").getBody(), true)); + Object healthy = warnings.invoke(bridge, function("rea_tls"), + currentProgram.getListing().getInstructions(function("rea_tls").getBody(), true)); require("healthy_boundary_no_warning", healthy instanceof List values && values.isEmpty()); - Object guarded = warnings.invoke(bridge, currentProgram.getListing().getInstructions(adjacentCaller.getBody(), true)); + Object guarded = warnings.invoke(bridge, adjacentCaller, + currentProgram.getListing().getInstructions(adjacentCaller.getBody(), true)); require("unrepaired_boundary_named", guarded instanceof List values && values.stream().anyMatch(value -> value.toString().contains("malloc"))); Method initialize = bridge.getClass().getDeclaredMethod("initializeDecompiler"); diff --git a/tests/conformance/ghidra/inventory.c b/tests/conformance/ghidra/inventory.c index e82c7d9ddb..1c4413262e 100644 --- a/tests/conformance/ghidra/inventory.c +++ b/tests/conformance/ghidra/inventory.c @@ -39,6 +39,12 @@ struct ReaFixtureLayout { volatile struct ReaFixtureLayout rea_ghidra_inventory_layout = { REA_FIXTURE_READY, {.word = 5}, 0}; +struct ReaFixtureBits { + unsigned int low : 3; + unsigned int high : 5; +}; +volatile struct ReaFixtureBits rea_ghidra_inventory_bits = {1, 2}; + __attribute__((noinline, used)) int rea_ghidra_inventory_leaf(int value) { puts("REA_GHIDRA_LEAF_VALUE"); return value + rea_ghidra_inventory_global; diff --git a/tests/conformance/native/ui-offscreen.swift b/tests/conformance/native/ui-offscreen.swift new file mode 100644 index 0000000000..599a1a836f --- /dev/null +++ b/tests/conformance/native/ui-offscreen.swift @@ -0,0 +1,14 @@ +import AppKit + +// A real window that exists and is never ordered in, so it stays off screen and +// never appears on the desktop. It expires in case its launcher loses track of it. +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let window = NSWindow(contentRect: NSRect(x: 120, y: 120, width: 240, height: 80), styleMask: [.titled], backing: .buffered, defer: false) +window.title = "REA off-screen window fixture" +DispatchQueue.main.async { + let record = "{\"pid\":\(ProcessInfo.processInfo.processIdentifier),\"window_id\":\(window.windowNumber)}\n" + FileHandle.standardOutput.write(Data(record.utf8)) +} +DispatchQueue.main.asyncAfter(deadline: .now() + 300) { exit(0) } +app.run() diff --git a/tests/fixtures/artifactComparisonMcp.ts b/tests/fixtures/artifactComparisonMcp.ts new file mode 100644 index 0000000000..f61f545012 --- /dev/null +++ b/tests/fixtures/artifactComparisonMcp.ts @@ -0,0 +1,37 @@ +import { expect } from "vitest"; + +import { artifactComparisonResultSchema } from "../../src/domain/artifactComparison.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence, type Evidence } from "../../src/domain/evidence.js"; +import { connectLocalToolsMcp } from "./localToolsMcp.js"; + +/** Return the inventory Evidence that an artifact inspection retains. */ +export const inventoryEvidence = (inspection: unknown): Evidence => { + const inventory = artifactInspectionResultSchema + .parse(parseEvidence(inspection).normalized_result) + .substeps.find( + ({ operation }) => operation === "inventory_artifact", + )?.evidence; + if (inventory === undefined) throw new Error("Missing inventory Evidence"); + return parseEvidence(inventory); +}; + +/** Inspect artifact files and compare their inventories through MCP. */ +export async function connectArtifactComparisonMcp() { + const { call } = await connectLocalToolsMcp(); + const inspect = async (path: string): Promise => { + const opened = await call("open_binary", { path }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const response = await call("inspect_artifact", {}); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return inventoryEvidence(response.structuredContent); + }; + const compare = async (left: Evidence, right: Evidence) => { + const response = await call("compare_artifacts", { left, right }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + return artifactComparisonResultSchema.parse( + parseEvidence(response.structuredContent).normalized_result, + ); + }; + return { call, inspect, compare }; +} diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 5ef111d92b..22b8336b23 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -182,6 +182,40 @@ const handleRequest = (socket, server, request, state) => { handlePing(socket, request, state); return; } + if ( + ["import_identity_unavailable", "import_identity_mismatch"].includes( + state.mode, + ) && + request.method !== "shutdown" + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: state.mode, + message: "Ghidra imported identity admission failed", + }, + })}\n`, + ); + return; + } + if ( + state.mode === "late_identity_failure" && + request.method === "list_strings" + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: "import_identity_mismatch", + message: "Unexpected later failure", + }, + })}\n`, + ); + return; + } if ( state.mode === "old_oom_then_malformed" && request.method === "list_documents" && @@ -341,6 +375,23 @@ const handleRequest = (socket, server, request, state) => { }; const handlePing = (socket, request, state) => { + if ( + ["import_identity_unavailable", "import_identity_mismatch"].includes( + state.mode, + ) + ) { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: false, + error: { + code: state.mode, + message: "Ghidra project identity admission failed", + }, + })}\n`, + ); + return; + } if (state.mode === "remote_startup_error") { socket.write( `${JSON.stringify({ diff --git a/tests/fixtures/firmware/fileSizeProducer.ts b/tests/fixtures/firmware/fileSizeProducer.ts new file mode 100644 index 0000000000..148af384c1 --- /dev/null +++ b/tests/fixtures/firmware/fileSizeProducer.ts @@ -0,0 +1,93 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { onTestFinished } from "vitest"; +import { createFirmwareAnalysisProvider } from "../../../src/composition/firmware.js"; +import { FirmwareAnalysisService } from "../../../src/application/firmware/FirmwareAnalysisService.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import type { assertFirmwareCleanup } from "./provider.js"; +import { createTestTempDirectory } from "../temporaryDirectory.js"; + +/** + * Run the production firmware provider and real prlimit with a synthetic + * Unblob producer that writes one staged file of attemptedBytes. Real Unblob + * has its own verifier. Python keeps SIGXFSZ ignored, so an over-limit write + * fails with EFBIG and leaves a truncated file, as the real producer would. + * The producer asserts the per-file limits it inherits and that the kernel + * left exactly min(attempted, limit) bytes, then reports what is on disk, so + * only REA's limit and inventory can expose the truncation. Python is used + * because Node cannot start under the 1 GiB address-space limit. + */ +export const firmwareFileSizeService = async (limits: { + attemptedBytes: number; + probeFileBytes: number; + extractionFileBytes: number; +}) => { + const { attemptedBytes, probeFileBytes, extractionFileBytes } = limits; + const directory = await createTestTempDirectory("rea-firmware-limits-"); + const command = join(directory, "unblob-probe"); + await writeFile( + command, + `#!/usr/bin/python3 +import errno, hashlib, json, os, resource, sys +if '--version' in sys.argv: + assert resource.getrlimit(resource.RLIMIT_FSIZE) == (${String(probeFileBytes)}, ${String(probeFileBytes)}) + print('26.6.4') + sys.exit(0) +assert resource.getrlimit(resource.RLIMIT_FSIZE) == (${String(extractionFileBytes)}, ${String(extractionFileBytes)}) +args = sys.argv[1:] +report = args[args.index('--report') + 1] +log = args[args.index('--log') + 1] +source = args[-1] +child = os.path.join(args[args.index('--extract-dir') + 1], 'rootfs', 'config') +with open(source, 'rb') as handle: + data = handle.read() +os.makedirs(os.path.dirname(child)) +with open(child, 'wb', buffering=0) as handle: + pending = memoryview(b'x' * ${String(attemptedBytes)}) + try: + while pending: + pending = pending[handle.write(pending):] + except OSError as error: + if error.errno != errno.EFBIG: + raise +with open(child, 'rb') as handle: + content = handle.read() +assert len(content) == min(${String(attemptedBytes)}, ${String(extractionFileBytes)}) +with open(log, 'w') as handle: + handle.write('resource fixture log\\n') +def stat(path, size): + return {'__typename__': 'StatReport', 'path': path, 'size': size, 'is_file': True, 'is_dir': False, 'is_link': False, 'link_target': None} +def task(path, depth, blob_id): + return {'path': path, 'depth': depth, 'blob_id': blob_id, 'is_multi_file': False} +def digest(value): + return {'__typename__': 'HashReport', 'sha256': hashlib.sha256(value).hexdigest()} +child_task = task(child, 1, 'gzip-id') +chunk = {'__typename__': 'ChunkReport', 'id': 'gzip-id', 'start_offset': 0, 'end_offset': len(data), 'size': len(data), 'handler_name': 'gzip', 'is_encrypted': False, 'extraction_reports': []} +with open(report, 'w') as handle: + json.dump([ + {'task': child_task, 'reports': [stat(child, len(content)), digest(content)], 'subtasks': []}, + {'task': task(source, 0, ''), 'reports': [stat(source, len(data)), digest(data), chunk], 'subtasks': [child_task]}, + ], handle) +`, + { mode: 0o700 }, + ); + const path = join(directory, "input.bin"); + await writeFile(path, "fixture-input-data"); + const launches: Parameters[0] = []; + const service = new FirmwareAnalysisService( + createFirmwareAnalysisProvider( + { ...process.env, REA_UNBLOB_COMMAND: command }, + async (options) => { + const spawned = await spawnOwnedProviderProcess(options); + launches.push({ + cwd: options.cwd, + pid: spawned.process.pid, + args: options.arguments, + }); + return spawned; + }, + ), + ); + onTestFinished(() => service.close()); + return { service, path, output: join(directory, "output"), launches }; +}; diff --git a/tests/fixtures/mcpSchemaValidation.ts b/tests/fixtures/mcpSchemaValidation.ts index 3e81a0a8d6..c082006275 100644 --- a/tests/fixtures/mcpSchemaValidation.ts +++ b/tests/fixtures/mcpSchemaValidation.ts @@ -2,7 +2,10 @@ import type { JsonSchemaType, JsonSchemaValidator, } from "@modelcontextprotocol/client"; -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { + addFormats, + AjvJsonSchemaValidator, +} from "@modelcontextprotocol/client/validators/ajv"; import { Ajv2020 } from "ajv/dist/2020.js"; import type { ValidateFunction } from "ajv"; @@ -19,9 +22,27 @@ export const createMcpSchemaValidation = () => { const key = JSON.stringify(schema); let validator = sdkValidators.get(key); if (validator === undefined) { - // Each full schema owns its default provider, including dialect dispatch. + // Keep shared definitions as compiled functions instead of expanding + // them at every reference. Other dialects retain SDK dispatch. + const provider = + schema.$schema === undefined || + schema.$schema === "https://json-schema.org/draft/2020-12/schema" + ? new AjvJsonSchemaValidator( + addFormats( + new Ajv2020({ + strict: false, + validateFormats: true, + validateSchema: false, + allErrors: true, + inlineRefs: false, + code: { optimize: false }, + }), + ), + ) + : new AjvJsonSchemaValidator(); + // Each full schema owns its provider, including dialect dispatch. // Distinct schemas may legally reuse the same $id in separate servers. - validator = new AjvJsonSchemaValidator().getValidator(JSON.parse(key)); + validator = provider.getValidator(JSON.parse(key)); sdkValidators.set(key, validator); } return validator as JsonSchemaValidator; @@ -44,6 +65,8 @@ export const createMcpSchemaValidation = () => { validator = new Ajv2020({ strict: false, validateFormats: false, + inlineRefs: false, + code: { optimize: false }, }).compile(JSON.parse(key)); assertionValidators.set(key, validator); } diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts new file mode 100644 index 0000000000..cf6e75ba23 --- /dev/null +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -0,0 +1,73 @@ +import { createJavaScriptRuntimeObservationEvidence } from "../../src/application/javascript/JavaScriptRuntimeObservationEvidence.js"; +import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../src/contracts/javascript/javascriptRuntimeReconciliationExample.js"; +import { javascriptRuntimeObservationSchema } from "../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../src/inspector/providerIdentity.js"; + +/** Synthetic, schema-validated capture; this is not a live Inspector observation. */ +export const nodeReconciliationFixture = (scriptObserved: boolean) => { + const location = { + kind: "file", + file_path: "/Applications/Example.app/Contents/Resources/app/renderer.js", + }; + const runtime = javascriptRuntimeObservationSchema.parse({ + runtime: { + product: "node.js/v24.22.1", + protocol_version: "1.3", + v8_version: null, + }, + target: { + target_id: "synthetic-node", + protocol_type: "node", + attached: null, + location, + runtime_kind: "unknown", + runtime_kind_authority: "not-declared", + }, + capture: { + observation_ms: 100, + events_observed: scriptObserved ? 1 : 0, + events_retained: scriptObserved ? 1 : 0, + events_dropped: 0, + metadata_bytes_retained: 0, + truncated: false, + truncation_reasons: [], + }, + scripts: { + items: scriptObserved + ? [ + { + script_key: `v8_script_${"4".repeat(64)}`, + location, + execution_context_key: null, + cdp_hash: null, + length: null, + is_module: null, + status: "observed-loaded", + }, + ] + : [], + observed_total: scriptObserved ? 1 : 0, + excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, + }, + execution_contexts: [], + directly_observed: [], + unavailable_without_instrumentation: [], + unknowns: ["Synthetic bounded capture."], + limitations: ["Synthetic fixture, not a live runtime."], + }); + return { + static_layers: [{ analysis: JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE }], + runtime_observations: [ + createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "synthetic-node", + observation_ms: 100, + }, + runtime, + V8_INSPECTOR_PROVIDER_IDENTITY, + ), + ], + }; +}; diff --git a/tests/fixtures/processRunTokenParserProbe.swift b/tests/fixtures/processRunTokenParserProbe.swift index ed359869d8..335ce435b3 100644 --- a/tests/fixtures/processRunTokenParserProbe.swift +++ b/tests/fixtures/processRunTokenParserProbe.swift @@ -193,6 +193,40 @@ struct ProcessRunTokenParserProbe { parse: .failure(.invalidBuffer), codeSigningFlags: { 0 } ) + // A restricted native process can expose an incomplete argv layout before + // the parser reaches the withheld environment (CI observed mdworker here). + let incompleteRestrictedArguments = [ + RunTokenReadError.invalidBufferAt(.pathPadding), + .invalidBufferAt(.arguments), + .appleVectorUnavailable, + ].map { failure in + [UInt32(0x0400_0000), UInt32(0x0000_0800), nil].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .failure(failure), + codeSigningFlags: { flags } + ) + return ["state": observation.state, "reason": observation.reason ?? ""] + } + } + let signedReadableTokens = [UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .success(realToken), + codeSigningFlags: { flags } + ) + return observation.state == "readable" && observation.run_id == "owned-token" + } + let signedInvalidTokens = [RunTokenReadError.duplicateToken, .invalidTokenEncoding].map { failure in + [UInt32(0x0400_0000), UInt32(0x0000_0800)].map { flags in + let observation = processRunTokenObservation( + pid: 1, + parse: .failure(failure), + codeSigningFlags: { flags } + ) + return ["state": observation.state, "reason": observation.reason ?? ""] + } + } let duplicateTokenFailsClosed: Bool do { _ = try parseProcessEnvironment(in: fixture( @@ -311,6 +345,9 @@ struct ProcessRunTokenParserProbe { "malformedBuffersRejected": malformedBuffersRejected, "invalidPointerSizeFailsClosed": invalidPointerSizeFailsClosed, "malformedStages": malformedStages, + "incompleteRestrictedArguments": incompleteRestrictedArguments, + "signedReadableTokens": signedReadableTokens, + "signedInvalidTokens": signedInvalidTokens, "malformedObservation": [ "state": malformedObservation.state, "reason": malformedObservation.reason as Any? ?? NSNull(), diff --git a/tests/fixtures/webRuntime.ts b/tests/fixtures/webRuntime.ts index 5b93bccd6d..9d7dcc1db0 100644 --- a/tests/fixtures/webRuntime.ts +++ b/tests/fixtures/webRuntime.ts @@ -162,3 +162,55 @@ const runtimeEvents = (command: FakeCdpCommand, origin: string) => { }, })); }; + +/** Positions Chrome 156 reported for an inline script in a helper frame. */ +export const negativeColumnScript = { + scriptId: "helper-inline", + executionContextId: 7, + startLine: 21, + startColumn: -60, + endLine: 21, + endColumn: -15, + scriptLanguage: "JavaScript", +} as const; +export const negativeColumnScriptUrl = (origin: string) => + `${origin}/empty.html?cross-origin-isolated=1`; + +/** + * Replays that script and a listener on its first line. Positions on a + * script's first line include its start column, so the listener's is negative. + */ +export const startNegativeColumnBrowser = () => + startRuntimeBrowser({ + commandEvents: (command, origin) => + command.method === "Debugger.enable" + ? [ + { + ...(command.sessionId === undefined + ? {} + : { sessionId: command.sessionId }), + method: "Debugger.scriptParsed", + params: { + ...negativeColumnScript, + url: negativeColumnScriptUrl(origin), + }, + }, + ] + : undefined, + commandResult: (command) => + command.method === "DOMDebugger.getEventListeners" + ? { + listeners: [ + { + type: "message", + useCapture: false, + passive: false, + once: false, + scriptId: negativeColumnScript.scriptId, + lineNumber: 21, + columnNumber: -50, + }, + ], + } + : undefined, + }); diff --git a/tests/fixtures/wxapkg.ts b/tests/fixtures/wxapkg.ts new file mode 100644 index 0000000000..58fa5211d0 --- /dev/null +++ b/tests/fixtures/wxapkg.ts @@ -0,0 +1,114 @@ +/** Synthetic WeChat mini-program package (wxapkg) builders for reader tests. */ + +export interface WxapkgMember { + readonly path: string; + readonly data: string | Buffer; + /** Override the declared member offset (absolute file position). */ + readonly offset?: number; + /** Override the declared member byte size. */ + readonly size?: number; +} + +export interface WxapkgOptions { + readonly firstMark?: number; + readonly lastMark?: number; + readonly info?: number; + readonly declaredFileCount?: number; + readonly declaredIndexLength?: number; + readonly declaredDataLength?: number; + /** + * Count the 4-byte member count inside `indexLength`. Row bytes and member + * offsets stay where they are; only the declared length grows by four. + */ + readonly indexLengthIncludesFileCount?: boolean; + /** Override the encoded row's name-length field for every member. */ + readonly nameLength?: number; + /** Bytes appended to the table region beyond the declared rows. */ + readonly tablePadding?: number; + /** Declare every member after the first at the first member's offset. */ + readonly aliasOffsets?: boolean; + /** Replace the whole package prefix (encrypted variants). */ + readonly magic?: Buffer; + /** Truncate the final package to this many bytes. */ + readonly truncate?: number; +} + +const utf8 = (value: string): Buffer => Buffer.from(value, "utf8"); +const u32 = (value: number): Buffer => { + const out = Buffer.alloc(4); + out.writeUInt32BE(value); + return out; +}; + +/** Encrypted PC-client package prefix, without plaintext members. */ +export const wxapkgEncryptedBytes = (): Buffer => + Buffer.concat([Buffer.from("V1MMWX", "latin1"), Buffer.alloc(64)]); + +/** Plain wxapkg: 18-byte header, name/offset/size rows, then verbatim data. */ +export const buildWxapkg = ( + members: readonly WxapkgMember[], + options: WxapkgOptions = {}, +): Buffer => { + if (options.magic !== undefined) + return Buffer.concat([ + options.magic, + ...members.map(({ data }) => + typeof data === "string" ? utf8(data) : data, + ), + ]); + const names = members.map(({ path }) => utf8(path)); + // Encoded rows carry the real name bytes regardless of any declared override. + const rowBytes = names.reduce((sum, name) => sum + 12 + name.length, 0); + const rowRegion = rowBytes + (options.tablePadding ?? 0); + const indexLength = + options.declaredIndexLength ?? + rowRegion + (options.indexLengthIncludesFileCount === true ? 4 : 0); + // A declared length override repositions the data. Including the member + // count changes only the length field; the rows still begin at byte 18. + const dataStart = + options.declaredIndexLength !== undefined + ? 18 + indexLength + : 18 + rowRegion; + let cursor = dataStart; + const rows: Buffer[] = []; + const payload: Buffer[] = []; + let firstOffset: number | undefined; + members.forEach((member, index) => { + const name = names[index]!; + const data = + typeof member.data === "string" ? utf8(member.data) : member.data; + const aliased = + options.aliasOffsets === true && index > 0 && firstOffset !== undefined; + const offset = aliased ? firstOffset! : (member.offset ?? cursor); + const size = member.size ?? data.length; + firstOffset ??= offset; + rows.push( + u32(options.nameLength ?? name.length), + name, + u32(offset), + u32(size), + ); + if (!aliased) { + payload.push(data); + if (offset === cursor) cursor = offset + size; + } + }); + const dataLength = + options.declaredDataLength ?? Math.max(0, cursor - dataStart); + const header = Buffer.concat([ + Buffer.from([options.firstMark ?? 0xbe]), + u32(options.info ?? 0), + u32(indexLength), + u32(dataLength), + Buffer.from([options.lastMark ?? 0xed]), + u32(options.declaredFileCount ?? members.length), + ]); + const table = Buffer.concat([ + ...rows, + Buffer.alloc(options.tablePadding ?? 0), + ]); + let bytes = Buffer.concat([header, table, ...payload]); + if (options.truncate !== undefined) + bytes = bytes.subarray(0, options.truncate); + return bytes; +}; diff --git a/tests/process-global/analysisEvidenceDelivery.test.ts b/tests/process-global/analysisEvidenceDelivery.test.ts new file mode 100644 index 0000000000..04fcd59197 --- /dev/null +++ b/tests/process-global/analysisEvidenceDelivery.test.ts @@ -0,0 +1,83 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +import { publishCliAnalysisEvidence } from "../../src/cli/analysisEvidenceDelivery.js"; +import { createEvidence } from "../../src/domain/evidence.js"; +import { parseEvidenceBundle } from "../../src/domain/evidenceBundle.js"; +import { ok } from "../../src/domain/result.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +vi.mock("node:v8", () => ({ + getHeapStatistics: () => ({ + total_available_size: 1, + heap_size_limit: 1, + }), +})); + +it("publishes CLI Evidence without re-authenticating the complete graph", async () => { + const root = await createTestTempDirectory("rea-cli-evidence-"); + const destination = join(root, "bundle.json"); + const evidence = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "fixture", + parameters: { payload_bytes: 4_000_000 }, + result: { payload: "x".repeat(4_000_000) }, + }, + { immutable: true }, + ); + + const output = await publishCliAnalysisEvidence(ok(evidence), { + providerId: "fixture", + operation: "fixture", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: destination, + }); + + expect(output).toMatchObject({ evidence_id: evidence.evidence_id }); + const bundle = parseEvidenceBundle( + JSON.parse(await readFile(destination, "utf8")), + ); + expect(bundle.records.map((record) => record.evidence_id)).toContain( + evidence.evidence_id, + ); +}); + +it("keeps the writer heap check for mutable source Evidence", async () => { + const root = await createTestTempDirectory("rea-cli-mutable-evidence-"); + const destination = join(root, "bundle.json"); + const evidence = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "fixture", + parameters: { payload_bytes: 1_000 }, + result: { payload: "x".repeat(1_000) }, + }, + { immutable: true }, + ); + const source = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { + operation: "source-fixture", + parameters: { payload_bytes: 1_000 }, + result: { payload: "y".repeat(1_000) }, + }, + ); + const mutableSource = JSON.parse(JSON.stringify(source)) as typeof source; + + const output = await publishCliAnalysisEvidence(ok(evidence), { + providerId: "fixture", + operation: "fixture", + signal: new AbortController().signal, + detail: "complete", + evidenceOutput: destination, + sourceEvidence: [mutableSource], + }); + + expect(output).toMatchObject({ code: "resource_constraint" }); +}); diff --git a/tests/process-global/derivedOperationFailures.test.ts b/tests/process-global/derivedOperationFailures.test.ts index 3312d4ffd1..c12e751bdd 100644 --- a/tests/process-global/derivedOperationFailures.test.ts +++ b/tests/process-global/derivedOperationFailures.test.ts @@ -33,6 +33,7 @@ import { OwnedFileHandle } from "../../src/filesystem/OwnedFileHandle.js"; import { primaryAnalysisError } from "../../src/domain/analysisErrorBase.js"; import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js"; import { ProcessCaptureError } from "../../src/process/capture/ProcessCaptureError.js"; import { reconstructionObligationLedgerSchema } from "../../src/domain/reconstructionObligationLedgerSchemas.js"; @@ -919,3 +920,53 @@ describe("native matching failure ownership through the public SDK", () => { }, ); }); + +describe("compare_wasm_interfaces Evidence attribution", () => { + const inspection = (selectedPath?: string) => { + const report = wasmArtifactFixture(); + return createEvidence( + { + path: report.artifact.path, + sha256: report.artifact.sha256, + format: "file", + }, + { id: "typed-fixture", name: "Typed fixture", version: "1" }, + { + operation: "inspect_wasm_artifact", + parameters: { path: selectedPath ?? report.artifact.path }, + result: report, + confidence: "observed", + }, + ); + }; + + it.each(["left", "right"] as const)( + "names the %s argument whose authentic Evidence selects another artifact", + async (side) => { + await withSdk(async ({ client, session }) => { + const rejected = inspection("/artifacts/other.wasm"); + const response = await client.callTool({ + name: "compare_wasm_interfaces", + arguments: { + left: side === "left" ? rejected : inspection(), + right: side === "right" ? rejected : inspection(), + scope: "both", + }, + }); + + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: [side], + reason: "invalid_value", + }), + ], + }, + }); + expect(session.exportEvidenceBundle().records).toEqual([]); + }); + }, + ); +}); diff --git a/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts b/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts new file mode 100644 index 0000000000..502575605d --- /dev/null +++ b/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import { access, readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { expect, it, onTestFinished } from "vitest"; +import { firmwareFileSizeLimitReached } from "../../src/firmware/FirmwareFiles.js"; +import { FIRMWARE_LIMITS } from "../../src/firmware/FirmwareRelease.js"; +import { firmwareFileSizeService } from "../fixtures/firmware/fileSizeProducer.js"; +import { assertFirmwareCleanup } from "../fixtures/firmware/provider.js"; + +const execFileAsync = promisify(execFile); +const budget = 16 * 1024 * 1024; + +// Lowers this isolated fork's soft RLIMIT_FSIZE to the staging budget, which +// removes the one byte of headroom the producer limit normally keeps. +it.skipIf(process.platform !== "linux")( + "rejects a staged file that reaches an inherited file-size ceiling", + async () => { + const limits = await readFile("/proc/self/limits", "utf8"); + const soft = /^Max file size\s+(\S+)/mu.exec(limits)?.[1]; + if (soft === undefined) throw new Error("No inherited file-size limit"); + const setSoftLimit = async (value: string): Promise => { + await execFileAsync("prlimit", [ + "--pid", + String(process.pid), + `--fsize=${value}:`, + ]); + }; + await setSoftLimit(String(budget)); + onTestFinished(() => setSoftLimit(soft)); + const fixture = await firmwareFileSizeService({ + attemptedBytes: budget + 4096, + probeFileBytes: FIRMWARE_LIMITS.reportBytes + 1, + extractionFileBytes: budget, + }); + const outcome = await fixture.service.execute("extract_firmware", { + path: fixture.path, + output_directory: fixture.output, + max_output_bytes: budget, + }); + expect(outcome).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisResourceConstraintError", + resource: "file-size", + reportedLimits: { file_size_bytes: budget }, + retryable: false, + // The inventory refusal, not the producer's SIGXFSZ recovery, owns this. + remediationAction: firmwareFileSizeLimitReached( + "extract_firmware", + budget, + ).remediationAction, + }, + }); + await expect(access(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + await assertFirmwareCleanup(fixture.launches); + }, +); diff --git a/tests/process-global/hopperDownloadHttpFailure.test.ts b/tests/process-global/hopperDownloadHttpFailure.test.ts index 98cd0c707a..07cd9cca02 100644 --- a/tests/process-global/hopperDownloadHttpFailure.test.ts +++ b/tests/process-global/hopperDownloadHttpFailure.test.ts @@ -95,7 +95,7 @@ const vendor = ( return { fetch }; }; const failed = (status: number, cancel?: () => void) => () => - new Response(new ReadableStream({ ...(cancel ? { cancel } : {}) }), { + new Response(new ReadableStream(cancel ? { cancel } : {}), { status, }); diff --git a/tests/process-global/mainStartupCancellation.test.ts b/tests/process-global/mainStartupCancellation.test.ts index e352250677..63dde05465 100644 --- a/tests/process-global/mainStartupCancellation.test.ts +++ b/tests/process-global/mainStartupCancellation.test.ts @@ -75,9 +75,13 @@ it.each([ await new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }), ); - return err( - new AnalysisCancelledError("open_binary", { cause: signal.reason }), - ); + return { + ok: false, + error: new AnalysisCancelledError("open_binary", { + cause: signal.reason, + }), + selection: { kind: "none" }, + }; }); let closeFails = failedClose; const close = vi.spyOn(session, "close").mockImplementation(async () => @@ -139,7 +143,7 @@ it.each(["optional", "late-handle"] as const)( "closes resources settling after startup cancellation at %s", async (stage) => { const session = sessionOwner(); - vi.spyOn(session, "open").mockResolvedValue(ok(target)); + vi.spyOn(session, "open").mockResolvedValue({ ok: true, value: target }); const closeSession = vi.spyOn(session, "close").mockResolvedValue(ok(null)); const input = new PassThrough(); const signals = new EventEmitter(); diff --git a/tests/support/reverseEngineeringCompositionEdgeCases.ts b/tests/support/reverseEngineeringCompositionEdgeCases.ts index 6886226a70..8863974da8 100644 --- a/tests/support/reverseEngineeringCompositionEdgeCases.ts +++ b/tests/support/reverseEngineeringCompositionEdgeCases.ts @@ -327,7 +327,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => { ...(code === "ETIMEDOUT" ? { timeoutMs: 120_000 } : {}), ...(code === "EACCES" || code === "EPERM" ? { - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", @@ -337,7 +337,7 @@ const registerSharedReverseEngineeringEdgeCasesPart3 = (): void => { : {}), ...(code === "unknown" ? { - providerId: "objdump", + providerId: "objdump.command", reason: "Unable to run configured command (unknown error)", } : {}), @@ -425,7 +425,7 @@ const registerSharedReverseEngineeringEdgeCasesPart4 = (): void => { ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", - providerId: "objdump", + providerId: "objdump.command", diagnostics: { boundary: "provider-launch", command: "objdump", @@ -535,7 +535,7 @@ const registerSharedReverseEngineeringEdgeCasesPart5 = (): void => { ok: false, error: { _tag: "AnalysisCapabilityUnavailableError", - providerId: "objdump", + providerId: "objdump.command", reason: "Unable to run configured command (ENOENT)", cause: { message: "launch failed" }, }, diff --git a/website/README.md b/website/README.md index 47ea63ca14..d8cd2d5f41 100644 --- a/website/README.md +++ b/website/README.md @@ -486,10 +486,66 @@ The mask SVG is drawn from the seventh decoded mask. It selects zero-based position 21 and compares its character code with 55 (`7`). Keep the figure, solver constants and evidence notes aligned when changing this case. +## Agent discovery and Markdown + +`scripts/website_agents.py` runs during asset preparation, after translations. +It generates these public files in ignored `_site/`: + +- `.well-known/agent-skills/index.json`: the draft v0.2.0 skills discovery index. + Its archive contains the maintained + `.agents/skills/reverse-engineer-anything/SKILL.md` and Markdown references. + The name and description come from YAML frontmatter; SHA-256 covers the + complete, reproducible ZIP, so reference changes invalidate the digest too. +- `.well-known/ard.json`: an ARD manifest with representative analyst queries. +- `.well-known/ai-catalog.json`: an AI Catalog 1.0 document listing that same + downloadable skill. These advertise installation instructions for the local + tool, without claiming a hosted analysis endpoint. +- `index.md` beside each indexable rendered reading page, including translations. + Markdown retains code, evidence disclosures and image descriptions, with + absolute resource links. Drafts, labeled English fallbacks and target example + applications retain their HTML and indexing policy. + +The [skills discovery draft](https://github.com/cloudflare/agent-skills-discovery-rfc), +[ARD proposal](https://agenticresourcediscovery.org/spec/) and +[AI Catalog proposal](https://ai-catalog.io/) are evolving formats. ARD v0.91 +uses `ard.json` and `rel="ard"`; AI Catalog uses its separate `ai-catalog.json` +envelope. Both entries point to the same ZIP with the registered +`application/zip` media type. + +Cloudflare's Worker selects Markdown when an explicit `Accept: text/markdown` +has higher quality than HTML. HTML wins ties and remains the default. Successful +reading-page responses include `Vary: Accept` and an alternate Markdown Link; +Markdown uses `Content-Type: text/markdown; charset=utf-8`. Static `_headers` +advertises discovery resources, sets discovery CORS to `*`, and supplies MIME +types. Build-generated HTML `` elements also advertise these resources. + +GitHub Pages and the Python preview serve the same files and HTML links, with +direct Markdown URLs such as `guides/javascript/index.md`. They do not execute +the Worker or apply Cloudflare's `_headers`: content negotiation, custom MIME +overrides and discovery CORS headers are Cloudflare behavior. The publish +workflow packages `.well-known` explicitly because `upload-pages-artifact` +excludes dot directories by default. + +`python3 scripts/website.py check` includes artifact/content regressions and +Worker request-contract tests. To check the actual local Cloudflare runtime +after preparing assets, use the repository's pinned Node.js and Wrangler: + +```sh +npx wrangler@4.149.0 dev --cwd website --env production --local +curl -i -H 'Accept: text/markdown' http://localhost:8787/guides/javascript/ +curl -I http://localhost:8787/.well-known/agent-skills/index.json +``` + +The `production` environment here skips the preview build and uses the already +verified artifact; `--local` runs on your machine. Check HTML and Markdown, +GET and HEAD, discovery CORS, the downloaded ZIP digest, and unknown routes. +Production remains published only by the manual website workflow. + ## Cloudflare Workers `wrangler.toml` serves `website/_site/` through Workers Static Assets. There is -no Worker script or frontend framework. Before a preview or deployment, Wrangler +one small Worker (`worker.mjs`) for Markdown content negotiation and no frontend +framework. Before a preview or deployment, Wrangler runs the Python scripts to build the language editions, prepare the example ZIP and verify the website. Node.js and Python 3 must be available; the commands below use a pinned Wrangler version without adding it to REA's package dependencies. diff --git a/website/public/_headers b/website/public/_headers new file mode 100644 index 0000000000..2065dd5df3 --- /dev/null +++ b/website/public/_headers @@ -0,0 +1,11 @@ +/* + Link: ; rel="ard", ; rel="ai-catalog", ; rel="related"; type="application/json"; title="Agent skills index" + +/.well-known/* + Access-Control-Allow-Origin: * + +/.well-known/ai-catalog.json + Content-Type: application/ai-catalog+json + +/*.md + Content-Type: text/markdown; charset=utf-8 diff --git a/website/requirements.txt b/website/requirements.txt index e6099d25dc..67e1541cfd 100644 --- a/website/requirements.txt +++ b/website/requirements.txt @@ -2,3 +2,6 @@ CairoSVG==2.9.1 # Standard gettext PO/POT catalogs; no translation service is used by builds. polib==1.2.0 +# Build Markdown reading pages and parse the maintained skill's YAML metadata. +markdownify==1.2.3 +PyYAML==6.0.3 diff --git a/website/worker.mjs b/website/worker.mjs new file mode 100644 index 0000000000..b87bf8acfb --- /dev/null +++ b/website/worker.mjs @@ -0,0 +1,103 @@ +// Negotiate prebuilt Markdown while keeping the static asset service's routing. +function prefersMarkdown(accept) { + const ranges = (accept ?? "") + .toLowerCase() + .split(",") + .map((range) => { + const [type, ...parameters] = range.trim().split(";"); + const quality = parameters + .map((value) => value.trim()) + .find((value) => value.startsWith("q=")); + const q = quality === undefined ? 1 : Number(quality.slice(2)); + return { + type: type.trim(), + q: Number.isFinite(q) && q >= 0 && q <= 1 ? q : 0, + }; + }); + const quality = (type) => { + for (const match of [type, "text/*", "*/*"]) { + const matches = ranges.filter((range) => range.type === match); + if (matches.length) return Math.max(...matches.map((range) => range.q)); + } + return 0; + }; + // Wildcards alone never opt a browser into Markdown; HTML wins ties. + return ( + ranges.some((range) => range.type === "text/markdown") && + quality("text/markdown") > 0 && + quality("text/markdown") > quality("text/html") + ); +} + +function advertiseMarkdown(response, path) { + const vary = new Set( + (response.headers.get("Vary") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + if (![...vary].some((s) => s.toLowerCase() === "accept" || s === "*")) + vary.add("Accept"); + response.headers.set("Vary", [...vary].join(", ")); + response.headers.append( + "Link", + `<${path}>; rel="alternate"; type="text/markdown"`, + ); + return response; +} + +export default { + async fetch(request, env) { + const useMarkdown = prefersMarkdown(request.headers.get("Accept")); + // HTML validators describe a different representation. Probe the route first, + // then let the asset service evaluate validators/ranges against the Markdown. + const probe = new Request(request); + if (useMarkdown) { + for (const name of [ + "If-None-Match", + "If-Modified-Since", + "If-Match", + "If-Unmodified-Since", + "If-Range", + "Range", + ]) + probe.headers.delete(name); + } + const original = await env.ASSETS.fetch(probe); + if ( + ![200, 206, 304].includes(original.status) || + !["GET", "HEAD"].includes(request.method) || + !original.headers.get("Content-Type")?.startsWith("text/html") + ) + return original; + + const url = new URL(request.url); + if (!url.pathname.endsWith("/") && !url.pathname.endsWith(".html")) + return original; + const markdown = new URL(url); + markdown.pathname = url.pathname.endsWith("/") + ? `${url.pathname}index.md` + : url.pathname.slice(0, -5) + ".md"; + const candidate = await env.ASSETS.fetch( + useMarkdown + ? new Request(markdown, request) + : new Request(markdown, { method: "HEAD" }), + ); + if (!candidate.ok && ![304, 412, 416].includes(candidate.status)) { + await candidate.body?.cancel(); + return original; + } + const response = new Response( + useMarkdown ? candidate.body : original.body, + useMarkdown ? candidate : original, + ); + if (useMarkdown) { + await original.body?.cancel(); + if (candidate.status < 400) + response.headers.set("Content-Type", "text/markdown; charset=utf-8"); + } else { + await candidate.body?.cancel(); + } + return advertiseMarkdown(response, markdown.pathname); + }, +}; diff --git a/website/worker.test.mjs b/website/worker.test.mjs new file mode 100644 index 0000000000..7c3ae74f9f --- /dev/null +++ b/website/worker.test.mjs @@ -0,0 +1,127 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import worker from "./worker.mjs"; + +function assets({ markdownStatus = 200 } = {}) { + return { + ASSETS: { + async fetch(request) { + const url = new URL(request.url); + const markdown = url.pathname.endsWith("index.md"); + if (url.pathname === "/missing/") + return new Response("Missing", { status: 404 }); + if (url.pathname === "/guide") + return Response.redirect(`${url.origin}/guide/`, 307); + const headers = { + "Content-Type": markdown ? "text/plain" : "text/html", + ETag: markdown ? '"markdown"' : '"html"', + Link: '; rel="ard"', + }; + const status = + request.headers.get("If-None-Match") === headers.ETag + ? 304 + : markdown + ? markdownStatus + : 200; + return new Response( + request.method === "HEAD" || status === 304 + ? null + : markdown + ? "# Guide" + : "

Guide

", + { headers, status }, + ); + }, + }, + }; +} + +await test("HTML is the default and explicit Accept preferences select Markdown", async () => { + for (const [accept, markdown] of [ + [null, false], + ["*/*", false], + ["text/html", false], + ["text/markdown", true], + ["text/markdown;q=0", false], + ["text/markdown;q=0.5,text/html", false], + ["text/markdown,text/html;q=0.5", true], + ["text/markdown,text/html", false], + ["TEXT/MARKDOWN; q=1,text/*;q=0.5", true], + ["text/markdown;q=bad", false], + ["text/markdown;q=0,*/*;q=1", false], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: accept === null ? {} : { Accept: accept }, + }), + assets(), + ); + assert.equal( + response.headers.get("Content-Type"), + markdown ? "text/markdown; charset=utf-8" : "text/html", + accept, + ); + assert.equal( + await response.text(), + markdown ? "# Guide" : "

Guide

", + accept, + ); + assert.match(response.headers.get("Vary"), /Accept/); + assert.match(response.headers.get("Link"), /rel="ard"/); + assert.match( + response.headers.get("Link"), + /<\/guide\/index.md>; rel="alternate"/, + ); + } +}); + +await test("HEAD and validators use the selected representation", async () => { + for (const method of ["GET", "HEAD"]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + method, + headers: { Accept: "text/markdown", "If-None-Match": '"html"' }, + }), + assets(), + ); + assert.equal(response.status, 200); + assert.equal(response.headers.get("ETag"), '"markdown"'); + assert.equal(await response.text(), method === "HEAD" ? "" : "# Guide"); + } + for (const [accept, tag] of [ + ["text/html", '"html"'], + ["text/markdown", '"markdown"'], + ]) { + const response = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: accept, "If-None-Match": tag }, + }), + assets(), + ); + assert.equal(response.status, 304); + assert.equal(await response.text(), ""); + assert.match(response.headers.get("Vary"), /Accept/); + } +}); + +await test("missing Markdown keeps HTML, and asset redirects and missing routes survive", async () => { + const html = await worker.fetch( + new Request("https://rea.tools/guide/", { + headers: { Accept: "text/markdown" }, + }), + assets({ markdownStatus: 404 }), + ); + assert.equal(await html.text(), "

Guide

"); + for (const [path, status] of [ + ["/guide", 307], + ["/missing/", 404], + ]) { + const response = await worker.fetch( + new Request(`https://rea.tools${path}`, { + headers: { Accept: "text/markdown" }, + }), + assets(), + ); + assert.equal(response.status, status); + } +}); diff --git a/website/wrangler.toml b/website/wrangler.toml index cb3f21b386..649d31cfe1 100644 --- a/website/wrangler.toml +++ b/website/wrangler.toml @@ -1,18 +1,26 @@ # Serve only the public website. The default deployment is a workers.dev preview. name = "rea-website-preview" +main = "worker.mjs" compatibility_date = "2026-10-08" workers_dev = true preview_urls = false [assets] directory = "./_site" +binding = "ASSETS" +run_worker_first = ["/", "/*/", "/*.html"] html_handling = "auto-trailing-slash" not_found_handling = "none" [build] cwd = ".." command = "python3 scripts/website.py check" -watch_dir = ["./public", "./locales", "./templates"] +watch_dir = [ + "./public", + "./locales", + "./templates", + "../.agents/skills/reverse-engineer-anything", +] # Public HTTPS depends on zone activation and certificate readiness. [env.production]