From 9c14999efd94ae04bfaf6532a74613b7a914810c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AE=B0=E5=BE=97=E5=90=83=E4=B8=AD=E9=A5=AD?= <17373302683@163.com> Date: Tue, 13 Oct 2026 21:13:01 +0800 Subject: [PATCH] feat(ghidra): support raw NMOS 6502 analysis (#2079) * feat(ghidra): admit raw NMOS 6502 analysis * test(ghidra): require verified POSIX verifier cleanup * test(ghidra): verify 6502 low-memory raw imports * docs(skill): document raw NMOS 6502 support * fix(ghidra): preserve requested Windows raw source paths * docs(ghidra): include raw 6502 in Windows installation scope * test(ghidra): verify Windows raw junction rejection through CLI and MCP * fix(validation): make fixtures portable and preserve explicit Codex home * fix(ghidra): attest raw mapping state and address space * fix(ghidra): align diagnostics and regression groups * fix(ghidra): bind raw attestation to language address space * chore(ghidra): avoid verifier insertion conflict * docs(ghidra): align linked raw admission guides * fix(ghidra): retain 6502 verifier workspace on failure * test(ghidra): isolate Windows authority doctor fixture * fix(binary): re-admit Windows raw paths on session reopen * test(binary): require fresh provider execution after path change * Constrain 6502 to raw lifecycle and snapshot targets * Bypass saved reads for unadmitted Windows paths * Guard direct CLI snapshot replay on Windows paths * fix(snapshot): gate Windows Ghidra replay on native admission * fix(snapshot): constrain nested 6502 subjects to raw images * test: make dyld slice fixtures portable --- .../references/native-and-artifacts.md | 7 +- bridge/ghidra/ReaGhidraPrepareRaw.java | 17 +- docs/cli.md | 4 +- docs/ghidra-mips.md | 2 +- docs/ghidra-riscv.md | 4 +- docs/installation.md | 5 +- docs/native-investigation.md | 7 +- docs/raw-binary.md | 63 ++- docs/testing.md | 10 + docs/windows-ghidra-p0.md | 14 +- .../analysis-efficiency/agent-trial.py | 4 +- .../analysis-efficiency/test_codex_context.py | 5 +- package.json | 1 + scripts/verify-real-ghidra-6502.mjs | 479 ++++++++++++++++++ src/application/BinaryTargetResolver.ts | 12 +- src/application/DirectAnalysis.ts | 6 + .../binary/AnalysisSnapshotCache.ts | 5 + src/application/binary/BinarySession.ts | 12 + .../binary/BinarySessionExecution.ts | 16 +- src/application/binary/BinarySessionOpen.ts | 18 +- src/contracts/toolOutputReferences.test.ts | 28 + src/contracts/toolOutputSchemaGroups.ts | 2 +- src/domain/analysisSnapshot.test.ts | 67 ++- src/domain/analysisSnapshot.ts | 75 ++- src/domain/binaryTarget.ts | 4 +- src/domain/binaryTargetTypes.ts | 16 +- src/domain/evidence.test.ts | 27 + src/domain/evidence.ts | 76 +-- src/domain/evidenceBundle.ts | 23 +- src/domain/native/nativeLoadImage.ts | 3 +- src/domain/rawBinary.test.ts | 38 ++ src/domain/rawBinary.ts | 13 +- src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraLoadImageValues.ts | 33 +- src/ghidra/GhidraProvider.behavior.test.ts | 73 ++- src/ghidra/GhidraProvider.ts | 9 +- src/ghidra/GhidraProviderCapabilities.ts | 2 +- src/ghidra/GhidraProviderClient.ts | 2 +- src/ghidra/GhidraRawBinaryLanguage.ts | 37 +- src/ghidra/GhidraRawBinaryLoadImage.test.ts | 138 +++++ src/ghidra/GhidraRiscvProfile.test.ts | 11 +- src/hopper/HopperAnalysisProfile.ts | 3 +- src/hopper/HopperMachOImage.ts | 11 +- tests/boundary/cli/dylibResolutionCli.test.ts | 16 +- tests/boundary/cli/rawBinaryCli.test.ts | 30 ++ .../directAnalysisSnapshotReplay.test.ts | 95 +++- .../javascriptArtifactReconstruction.test.ts | 6 +- .../filesystem/rawBinaryTarget.test.ts | 39 +- tests/boundary/mcp/rawBinaryMcp.test.ts | 231 ++++++++- ...actProvider.extractionCompleteness.test.ts | 2 +- tests/conformance/ghidra/6502.cfg | 9 + tests/conformance/ghidra/6502.s | 19 + tests/conformance/readmeConsistency.test.ts | 14 + tests/fixtures/strippedAsarAddon.ts | 2 +- 54 files changed, 1650 insertions(+), 197 deletions(-) create mode 100644 scripts/verify-real-ghidra-6502.mjs create mode 100644 tests/conformance/ghidra/6502.cfg create mode 100644 tests/conformance/ghidra/6502.s 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 6a942c73c..a4fc6d18f 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -224,10 +224,13 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`, -`arm`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at +`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at that address. Hopper and IDA refuse this interpretation; Ghidra uses `BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only, -and Cortex-M variants are unsupported. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md). +and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and +complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge +containers, bank switching, and mapper semantics are unsupported. Windows +Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md). ### Function annotations in Ghidra diff --git a/bridge/ghidra/ReaGhidraPrepareRaw.java b/bridge/ghidra/ReaGhidraPrepareRaw.java index 1a5db9627..143331390 100644 --- a/bridge/ghidra/ReaGhidraPrepareRaw.java +++ b/bridge/ghidra/ReaGhidraPrepareRaw.java @@ -1,6 +1,8 @@ import ghidra.app.script.GhidraScript; import ghidra.program.model.address.Address; import ghidra.program.model.listing.Program; +import ghidra.program.model.mem.MemoryBlock; +import ghidra.program.model.mem.MemoryBlockSourceInfo; /** Seed the caller-supplied raw-binary entry before default auto-analysis. */ public final class ReaGhidraPrepareRaw extends GhidraScript { @@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript { ); } Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex); - Address start = currentProgram.getMinAddress(); - if (entry == null || !entry.equals(start)) { + MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry); + boolean fileStart = false; + if (block != null) { + // Processor-defined zero page/stack blocks can precede the imported file. + for (MemoryBlockSourceInfo source : block.getSourceInfos()) { + if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 && + entry.equals(source.getMinAddress())) { + fileStart = true; + break; + } + } + } + if (!fileStart) { throw new IllegalArgumentException( "Raw binary import must map file offset 0 at the caller-supplied entry" ); diff --git a/docs/cli.md b/docs/cli.md index dcd949e11..6d81ec7c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json ``` -An exact CLI cache hit is read before starting a provider process. +An exact CLI cache hit is normally read before starting a provider process. +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 for its matching target; an MCP provider may still start before returning a cached result. `close_binary` accepts `snapshot_path` and optional diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 59d04a8b0..d5b58f00b 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so interpretation changes invalidate profile-bound snapshots. The support-lane identity is versioned independently of file identity. Evidence, lifecycle outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters -do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only. +do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets. ### Soft-float prototype diff --git a/docs/ghidra-riscv.md b/docs/ghidra-riscv.md index 5b3407361..afae01948 100644 --- a/docs/ghidra-riscv.md +++ b/docs/ghidra-riscv.md @@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The target also retains the raw ELF declarations that decide a lane: class, byte order, `e_type`, and `e_flags`. Recognition does not mean every provider or RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets -are unchanged, and caller-supplied headerless raw-binary entry still accepts only -its existing four families. +are unchanged. Headerless inputs use the separate [raw-binary admission +boundary](raw-binary.md); raw RISC-V images are not supported. The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`, diff --git a/docs/installation.md b/docs/installation.md index 3bd4e0928..d6dc8e3b0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -512,7 +512,10 @@ and `set_addresses_names` name a function entry or any other mapped address session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls require Hopper; Windows P0 remains read-only. -Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes. +Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly +selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied +entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain +unsupported on Windows. The npm package bundles native Job Object ownership, protected private runtime DACLs, and handle-based path admission; no separate addon installation is needed. See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 27381246b..5a2fcbd31 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason. Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires the matching native decompiler. Experimental Windows x64 P0 admits native -x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership, -protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and +x86 and x86-64 PE applications and DLLs, plus explicitly selected +[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address, +on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows. +The boundary uses bundled Job Object ownership, protected runtime DACLs, and +handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and [issue #527](https://github.com/morluto/rea/issues/527). On Linux and macOS, `annotate_native_function` atomically edits a function name and entry comments in the ephemeral database, returning refreshed analysis diff --git a/docs/raw-binary.md b/docs/raw-binary.md index 8e277568b..8d251d010 100644 --- a/docs/raw-binary.md +++ b/docs/raw-binary.md @@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina } ``` -`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a +`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a hexadecimal address of the form `0x…`. The complete file is mapped starting at that address, and Ghidra seeds an `entry` function there before auto-analysis. -ARM and AArch64 entries must be 4-byte aligned; x86 entries may be byte-aligned. +ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned. +For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`; +images that cross the 16-bit boundary are rejected before provider startup. `open_binary` and an open `binary_session` return the canonical `entry_point` for raw targets so callers can verify the selected mapping directly. Omitted `format` keeps header-based detection; architecture and entry without @@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without Ghidra imports a private snapshot with `BinaryLoader` and these little-endian generic languages and compiler specifications: -| architecture | language_id | compiler_spec_id | -| ------------ | ------------------- | ---------------- | -| x86 | `x86:LE:32:default` | `gcc` | -| x86_64 | `x86:LE:64:default` | `gcc` | -| arm | `ARM:LE:32:v7` | `default` | -| arm64 | `AARCH64:LE:64:v8A` | `default` | +| architecture | language_id | compiler_spec_id | +| ------------ | -------------------- | ---------------- | +| x86 | `x86:LE:32:default` | `gcc` | +| x86_64 | `x86:LE:64:default` | `gcc` | +| arm | `ARM:LE:32:v7` | `default` | +| arm64 | `AARCH64:LE:64:v8A` | `default` | +| 6502 | `6502:LE:16:default` | `default` | Compiler specifications select analysis conventions; they do not identify the original compiler or ABI of a headerless target. Ghidra's x86 languages do not @@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains `0x0` for BinaryLoader. Ghidra may report additional language-defined entry points, including unmapped ARM vectors. Load-image verification requires the caller-supplied entry and preserves those other entries as observations. +For 6502, BinaryLoader maps the file before adding its language-defined zero-page +and stack blocks. Conflicting default blocks are skipped; low-address files stay +in the default address space rather than an overlay. Non-conflicting uninitialized +blocks remain observations, not file-backed bytes. Independent raw verification streams the complete private snapshot digest and extent with bounded memory and supports cancellation during the read. +It also requires the source-backed block to be initialized, loaded, and +non-overlay in the bound language's default address space. Contradictory mapping +observations are retained with `mismatch`, even when the source hashes agree. -Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit -native PE applications and DLLs only; raw-binary targets are unsupported. +Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE +applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families +remain outside the Windows boundary. Native path admission, private DACLs, Job +Object ownership, immutable snapshots, and the no-mutation policy still apply. + +## NMOS 6502 + +```bash +rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json +rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json +``` + +Select only headerless NMOS 6502 code with a known entry at file offset 0. +The built-in Ghidra processor supplies static instructions, functions, +pseudocode, and references. Evidence preserves `architecture: "6502"`, the +selected language, entry, file digest, and verified source mapping. Register and +calling conventions are analysis conventions, not a recovered source ABI. + +This lane does not interpret iNES headers, cartridge mappers, bank switching, +or other console-specific memory layouts. 65C02 and 65816 programs need different +processor semantics and are not admitted as those variants. REA does not infer +the CPU from the file suffix, guess the reset vector, or emulate the target. + +`npm run verify:ghidra:6502` assembles and links the source-owned fixture with +`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`). +Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane +checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation, +invalid mappings, unchanged source bytes and runtime cleanup. +Low-memory checks include a file spanning the zero-page/stack boundary at `0xff` +and a complete 64-KiB image at `0x0`, with verified default-space mappings through +both CLI and MCP. On Windows, the lane also checks that a caller-selected +junction path is rejected at snapshot admission through CLI and MCP, with +unchanged source bytes and complete runtime cleanup. Windows requires the +matching native addon; the POSIX provider path has not been exercised by the +Windows run. ## Limitations diff --git a/docs/testing.md b/docs/testing.md index e8f36dc0c..7aa80d22a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -734,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly. It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS runtime or PC-98 device execution. +`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned +NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set +`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`). +With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP +mapping parity, exact source bytes, functions, a direct call reference, +decompilation, 16-bit overflow rejection without replacing the active session, +and runtime cleanup. Windows additionally requires the matching native addon; +its verifier lineage remains explicitly unavailable outside the provider's +retained Job Object, as in the other Windows lanes. + `npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image through the compiled CLI and stdio MCP server. With the same Ghidra/JDK prerequisites, it checks preparation and bridge startup, verified load mappings, diff --git a/docs/windows-ghidra-p0.md b/docs/windows-ghidra-p0.md index 9cd3bb6d8..5def0dae4 100644 --- a/docs/windows-ghidra-p0.md +++ b/docs/windows-ghidra-p0.md @@ -17,14 +17,16 @@ additional permission flag or degraded mode is required. - Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+; - an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4); - a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases); -- an explicit native, non-managed x86 or x86-64 PE application or DLL; and +- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a + [raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and - the 25 read-only Ghidra inventory, memory, and function-analysis operations. Loaded memory reads and file offsets preserve Ghidra's observed source mappings. PE load-image inspection returns an explicit unsupported attestation status. Independent verification covers admitted DOS MZ, explicit COM, and explicit -[raw-binary imports](raw-binary.md) on their supported hosts. This does not -extend Windows P0 admission beyond native PE. For x64 PE +[raw-binary imports](raw-binary.md) on their supported hosts, including the +admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited +to native PE and raw NMOS 6502 images. For x64 PE NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through `inspect_native_load_image` and `inspect_native_data_type` without a JAR or database mutation. A real Windows-host NativeAOT Ghidra run is still required @@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only operations remain unavailable through this provider. Managed PE/CLI inspection uses its separate execution-free provider. +Raw 6502 imports retain the same native filesystem and process ownership +requirements. The complete image must fit in the 16-bit address space; no +console-specific cartridge loader, CPU variant, or bank-switching model is +implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65` +to check the source-built fixture through real CLI and MCP workflows. + REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a compiler. The adapter uses the packaged Java `HeadlessScript`; Python and PyGhidra are not prerequisites. Users do not build the native addon. diff --git a/experiments/analysis-efficiency/agent-trial.py b/experiments/analysis-efficiency/agent-trial.py index 6dcc7d7f8..dc13e8781 100644 --- a/experiments/analysis-efficiency/agent-trial.py +++ b/experiments/analysis-efficiency/agent-trial.py @@ -94,6 +94,9 @@ def main(): if undeclared: raise ValueError('Declare inherited provider settings explicitly in --providers: ' + ', '.join(undeclared) + '. Values are not logged.') + source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ + else Path.home() / '.codex') + source_auth = source_auth_home / 'auth.json' args.output.mkdir(parents=True, exist_ok=False, mode=0o700) runtime_files, runtime_digest = runtime_fingerprint(repository) (args.output / 'runtime-files.json').write_text(json.dumps(runtime_files)) @@ -103,7 +106,6 @@ def main(): workspace = profile / 'workspace' for directory in [trial_codex_home, account, workspace]: directory.mkdir(mode=0o700) - source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json' auth = trial_codex_home / 'auth.json' started = time.monotonic() timeout = False diff --git a/experiments/analysis-efficiency/test_codex_context.py b/experiments/analysis-efficiency/test_codex_context.py index 37df83a7c..0b8e0247c 100644 --- a/experiments/analysis-efficiency/test_codex_context.py +++ b/experiments/analysis-efficiency/test_codex_context.py @@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase): self.assertNotIn(marker, text) self.assertIn('caller-selected evidence and paths', text) self.assertIn('original-response', text) - self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600) + # Windows mode bits do not describe access control; this is a POSIX assertion. + if os.name == 'posix': + self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600) def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self): with tempfile.TemporaryDirectory() as directory: @@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase): patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \ patch.object(agent.subprocess, 'Popen', return_value=child), \ patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \ + patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \ patch('builtins.print'): with self.assertRaisesRegex(OSError, 'fixture capture failure'): agent.main() diff --git a/package.json b/package.json index b2e2a51d3..7ee2b42ab 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs", "verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs", "verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs", + "verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs", "verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs", "verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs", "verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs", diff --git a/scripts/verify-real-ghidra-6502.mjs b/scripts/verify-real-ghidra-6502.mjs new file mode 100644 index 000000000..87fb01d41 --- /dev/null +++ b/scripts/verify-real-ghidra-6502.mjs @@ -0,0 +1,479 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + unlink, + 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 { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; + +if (process.argv.length !== 2) + throw new Error("Usage: node scripts/verify-real-ghidra-6502.mjs"); +if (process.env.GHIDRA_INSTALL_DIR === undefined) + throw new Error( + "Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.", + ); +const exec = promisify(execFile); +const run = createVerifierRun(); +const root = fileURLToPath(new URL("..", import.meta.url)); +const ca65 = process.env.REA_CA65 ?? "ca65"; +const ld65 = process.env.REA_LD65 ?? "ld65"; +const toolchain = {}; +for (const [name, command] of Object.entries({ ca65, ld65 })) { + const version = await exec(command, ["--version"], { timeout: 10000 }); + toolchain[name] = (version.stdout + version.stderr).trim(); +} +await mkdir(join(root, "build"), { recursive: true }); +const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-")); +const runtime = join(workspace, "runtime"); +await mkdir(runtime); +const path = join(workspace, "image.bin"); +const object = join(workspace, "image.o"); +const entrypoint = join(root, "scripts", "rea.mjs"); +const env = { + ...process.env, + TMPDIR: runtime, + TMP: runtime, + TEMP: runtime, + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "ghidra", + REA_PROCESS_RUN_ID: run.run_id, +}; +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", +}); +const client = new Client({ name: "rea-real-6502-proof", version: "1" }); +let stderr = ""; +transport.stderr?.on("data", (chunk) => { + stderr = (stderr + chunk.toString()).slice(-65536); +}); +let report; +try { + await exec( + ca65, + [join(root, "tests/conformance/ghidra/6502.s"), "-o", object], + { timeout: 10000 }, + ); + await exec( + ld65, + ["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object], + { timeout: 10000 }, + ); + const bytes = await readFile(path); + // Independently known opcodes and link addresses from the source fixture. + assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360"); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + await client.connect(transport); + const opened = await call("open_binary", { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0x008001", + provider_id: "ghidra", + }); + assert.equal(opened.architecture, "6502"); + assert.equal(opened.entry_point, "0x8001"); + const status = await call("binary_session"); + const image = evidence(await call("inspect_native_load_image"), sha256); + assert.equal( + image.normalized_result.status, + "verified", + JSON.stringify(image.normalized_result), + ); + assert.equal(image.normalized_result.architecture, "6502"); + assert.equal(image.normalized_result.base_address, "0x8001"); + assert.equal(image.normalized_result.language_id, "6502:LE:16:default"); + assert.equal(image.normalized_result.compiler_spec_id, "default"); + assert.equal(image.normalized_result.file_bytes, bytes.length); + const memory = evidence( + await call("read_bytes", { address: "0x8001", length: bytes.length }), + sha256, + ); + assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex")); + const procedures = evidence(await call("list_procedures"), sha256); + for (const address of ["0x8001", "0x8010"]) + assert.ok( + procedures.normalized_result.some( + (procedure) => procedure.address === address, + ), + ); + const instruction = evidence( + await call("inspect_native_instruction", { address: "0x8003" }), + sha256, + ); + assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR"); + assert.ok( + instruction.normalized_result.references.some( + (reference) => reference.call && reference.target_address === "0x8010", + ), + ); + const pseudo = evidence( + await call("procedure_pseudo_code", { procedure: "0x8010" }), + sha256, + ); + assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u); + const filePseudo = evidence( + await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }), + sha256, + ); + assert.equal(filePseudo.normalized_result, pseudo.normalized_result); + assert.ok( + filePseudo.locations.some( + (location) => location.kind === "file-offset" && location.offset === 15, + ), + ); + const rejected = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0xffff", + }, + }); + assert.equal(rejected.isError, true); + assert.match(JSON.stringify(rejected.content), /6502 address width/u); + const preserved = await call("binary_session"); + assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id); + assert.equal(preserved.entry_point, "0x8001"); + await call("close_binary"); + await client.ping(); + const { stdout } = await exec( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + path, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const cli = parseEvidence(JSON.parse(stdout)); + assert.deepEqual(cli.normalized_result, image.normalized_result); + assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest); + assert.equal(cli.subject.digest.sha256, sha256); + const fileCli = await exec( + process.execPath, + [ + entrypoint, + "decompile", + path, + "--file-offset=0xf", + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout)); + assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result); + assert.deepEqual(fileCliEvidence.locations, filePseudo.locations); + assert.equal(fileCliEvidence.subject.digest.sha256, sha256); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + const lowMemory = []; + // BinaryLoader maps file bytes before trying processor-defined blocks. Prove + // that overlapping zero-page/stack definitions never overlay the selected image. + for (const [entry, fullAddressSpace] of [ + [0xff, false], + [0, true], + ]) { + const lowPath = join(workspace, `low-${entry}.bin`); + await exec( + ld65, + [ + "-C", + join(root, "tests/conformance/ghidra/6502.cfg"), + "--start-addr", + String(entry), + "-o", + lowPath, + object, + ], + { timeout: 10000 }, + ); + const linked = await readFile(lowPath); + const expectedCode = Buffer.from( + "a907200000c90ad003a92a60a9006018690360", + "hex", + ); + expectedCode.writeUInt16LE(entry + 15, 3); + assert.deepEqual(linked, expectedCode); + const lowBytes = fullAddressSpace + ? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)]) + : linked; + await writeFile(lowPath, lowBytes); + const lowSha256 = createHash("sha256").update(lowBytes).digest("hex"); + const lowEntry = `0x${entry.toString(16)}`; + const lowOpened = await call("open_binary", { + path: lowPath, + format: "raw-binary", + architecture: "6502", + entry_point: lowEntry, + provider_id: "ghidra", + }); + assert.equal(lowOpened.entry_point, lowEntry); + const lowImage = evidence( + await call("inspect_native_load_image"), + lowSha256, + ); + assert.equal( + lowImage.normalized_result.status, + "verified", + JSON.stringify(lowImage.normalized_result), + ); + assert.equal(lowImage.normalized_result.base_address, lowEntry); + assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length); + const fileMappings = + lowImage.normalized_result.observations.mappings.filter( + (mapping) => mapping.source_file_index !== null, + ); + assert.equal(fileMappings.length, 1); + assert.equal( + fileMappings[0].address_space, + lowImage.normalized_result.observations.default_address_space, + ); + assert.equal(fileMappings[0].overlay, false); + assert.equal(fileMappings[0].start, lowEntry); + assert.equal(fileMappings[0].length, lowBytes.length); + const lowRead = evidence( + await call("read_bytes", { address: lowEntry, length: linked.length }), + lowSha256, + ); + assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex")); + const lowCall = evidence( + await call("inspect_native_instruction", { + address: `0x${(entry + 2).toString(16)}`, + }), + lowSha256, + ); + assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR"); + assert.ok( + lowCall.normalized_result.references.some( + (reference) => + reference.call && + reference.target_address === `0x${(entry + 15).toString(16)}`, + ), + ); + await call("close_binary"); + const lowCli = await exec( + process.execPath, + [ + entrypoint, + "inspect-native-load-image", + lowPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + lowEntry, + "--provider", + "ghidra", + "--json", + ], + { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 }, + ); + const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256); + assert.deepEqual( + lowCliEvidence.normalized_result, + lowImage.normalized_result, + ); + assert.equal( + lowCliEvidence.analysis_profile.digest, + lowImage.analysis_profile.digest, + ); + assert.deepEqual(await readFile(lowPath), lowBytes); + assert.deepEqual(await readdir(runtime), []); + lowMemory.push({ + entry: lowEntry, + bytes: lowBytes.length, + sha256: lowSha256, + }); + } + let windowsReparseAdmission = "not-applicable"; + if (process.platform === "win32") { + const junction = join(workspace, "linked"); + await symlink(workspace, junction, "junction"); + try { + const linkedPath = join(junction, "image.bin"); + // Reopening the same bytes through a junction must replace the direct session. + await call("open_binary", { + path, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + const directImage = evidence( + await call("inspect_native_load_image"), + sha256, + ); + assert.equal(directImage.normalized_result.status, "verified"); + // Canonical file identity must not bypass admission of the new path. + await call("open_binary", { + path: linkedPath, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + provider_id: "ghidra", + }); + // open_binary selects a lazy session; the first analysis admits the snapshot. + const linkedImage = await client.callTool( + { name: "inspect_native_load_image", arguments: {} }, + { timeout: 240000 }, + ); + assert.equal(linkedImage.isError, true); + assert.match( + JSON.stringify(linkedImage), + /Reparse path component is unsupported/u, + ); + await call("close_binary"); + const snapshotPath = join(workspace, "direct-snapshot.json"); + const cliArgs = (selectedPath) => [ + entrypoint, + "analyze", + selectedPath, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0x8001", + "--provider", + "ghidra", + "--snapshot", + snapshotPath, + "--json", + ]; + const saved = await exec(process.execPath, cliArgs(path), { + env, + timeout: 240000, + maxBuffer: 16 * 1024 * 1024, + }); + assert.equal( + evidence(JSON.parse(saved.stdout), sha256).operation, + "binary_overview", + ); + const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8")); + assert.ok( + savedSnapshot.workflow_entries.some( + ({ operation }) => operation === "binary_overview", + ), + ); + assert.deepEqual(await readdir(runtime), []); + // Replaying a matching snapshot must still reach native path admission. + await assert.rejects( + exec(process.execPath, cliArgs(linkedPath), { + env, + timeout: 240000, + maxBuffer: 16 * 1024 * 1024, + }), + (cause) => { + assert.match( + cause.stdout + cause.stderr, + /Reparse path component is unsupported/u, + ); + return true; + }, + ); + assert.deepEqual(await readFile(path), bytes); + assert.deepEqual(await readdir(runtime), []); + windowsReparseAdmission = "cli-snapshot-mcp-rejected"; + } finally { + await unlink(junction); + } + } + report = { + ok: true, + mocked: false, + lane: "ghidra-6502", + host: process.platform, + toolchain, + provider: image.provider, + target: { + sha256, + bytes: bytes.length, + architecture: "6502", + entry: "0x8001", + }, + cli_mcp_load_image_parity: true, + instructions_and_call_reference: true, + decompilation: true, + file_offset_decompilation_cli_mcp: true, + invalid_mapping_preserves_session: true, + low_memory_cli_mcp_parity: lowMemory, + windows_reparse_admission: windowsReparseAdmission, + source_unchanged: true, + runtime_cleanup: "complete", + }; +} catch (cause) { + if (stderr) process.stderr.write(stderr); + throw cause; +} finally { + try { + await client.close(); + } finally { + await transport.close(); + } +} +const verifierRun = await completeVerifierRun(run); +if (process.platform !== "win32") { + assert.equal(verifierRun.process_lineage.status, "verified"); + assert.deepEqual(verifierRun.process_lineage.descendants, []); +} +// Keep the workspace for diagnosis unless verification and cleanup both pass. +await rm(workspace, { recursive: true, force: true }); +process.stdout.write( + JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n", +); + +async function call(name, args = {}) { + const result = await client.callTool( + { name, arguments: args }, + { timeout: 240000 }, + ); + assert.notEqual(result.isError, true, JSON.stringify(result)); + if (["open_binary", "binary_session", "close_binary"].includes(name)) + return requireMcpOperationResult(result, name); + return result.structuredContent; +} + +function evidence(value, sha256) { + const result = parseEvidence(value); + assert.equal(result.subject.digest.sha256, sha256); + assert.equal(result.subject.architecture, "6502"); + assert.equal(result.subject.format, "raw-binary"); + return result; +} diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index ac691b55e..6f1bc4366 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -76,6 +76,8 @@ export const parseBinaryTarget = async ( await access(candidate, constants.R_OK); throwIfTargetResolutionCancelled(signal); const canonical = await realpath(candidate); + // Windows native admission must inspect the caller-selected reparse components. + const sourcePath = process.platform === "win32" ? candidate : canonical; throwIfTargetResolutionCancelled(signal); const resolved = await resolveAppBundle(canonical); throwIfTargetResolutionCancelled(signal); @@ -113,7 +115,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", format: "raw-binary", @@ -152,7 +154,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", format: "dos-com", @@ -166,7 +168,7 @@ export const parseBinaryTarget = async ( ) return ok({ path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), kind: "database", format: "analysis-database", @@ -175,7 +177,7 @@ export const parseBinaryTarget = async ( if (artifactFormat !== undefined) { const identity = { path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, sha256: await sha256Handle(handle, path, metadata, signal), }; return isArchiveFormat(artifactFormat) @@ -195,7 +197,7 @@ export const parseBinaryTarget = async ( ); return ok({ path, - sourcePath: process.platform === "win32" ? candidate : canonical, + sourcePath, ...bundle, sha256: await sha256Handle(handle, path, metadata, signal), kind: "executable", diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index c4d130f63..0bdb69864 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -23,6 +23,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { access } from "node:fs/promises"; import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js"; +import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { @@ -254,9 +255,14 @@ const runAnalysis = async ( const route = preview.value.route; const bindingProfile = route.profile ?? undefined; const evidenceProfile = analysisProfileForRoute(route, tool); + // A parsed Windows path does not prove Ghidra's native snapshot owner + // admitted the selected source; CLI replay must wait for that client. if ( bindingProfile !== undefined && evidenceProfile !== undefined && + !requiresNativeSourceAdmissionBeforeReplay( + route.binding?.identity.id, + ) && allowsSnapshotReplay(route, tool) ) { const cached = snapshotEvidenceForQuery(snapshot, { diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 7e307baa0..79e212cac 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -84,6 +84,11 @@ export const isSnapshotCacheable = ( descriptor.effects.mayWriteFilesystem === false) && descriptor.effects.changesPermissions === false; +/** Windows Ghidra must admit the selected source through its native snapshot owner. */ +export const requiresNativeSourceAdmissionBeforeReplay = ( + providerId: string | undefined, +): boolean => process.platform === "win32" && providerId === "ghidra"; + /** Bounded in-memory cache for one immutable binary identity. */ export class AnalysisSnapshotCache { readonly #entries = new Map(); diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index f7b2d3529..3da571832 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -58,6 +58,7 @@ interface SessionBinding { readonly profile: AnalysisProfileCommitment | null; readonly route: SessionProviderRoute; readonly runId: string; + nativeSourceAdmitted: boolean; } /** @@ -323,6 +324,7 @@ export class BinarySession profile, route, runId, + nativeSourceAdmitted: false, }; this.#clearRuntimeAvailability(); this.resetSnapshotInvalidation(); @@ -609,6 +611,7 @@ export class BinarySession active: this.#active, operation: name, parameters: arguments_, + nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true, unboundOperationError: (operation, route) => this.#providerRouter.unboundOperationError(operation, route), lookupSnapshot: (target, profile, operation, parameters) => @@ -645,6 +648,14 @@ export class BinarySession }, }) : profiled; + // A successful Ghidra operation proves this client's native source snapshot + // was admitted; a composite health response and parsed path do not. + if ( + execution.ok && + capability.provider.id === "ghidra" && + this.#active === active + ) + this.#active.nativeSourceAdmitted = true; this.#observeRuntimeAvailability(name, execution); if ( execution.ok && @@ -838,6 +849,7 @@ export class BinarySession profile: previous.profile, route: previous.route, runId, + nativeSourceAdmitted: false, }; return ok(null); } diff --git a/src/application/binary/BinarySessionExecution.ts b/src/application/binary/BinarySessionExecution.ts index 996749aae..73dfe4eea 100644 --- a/src/application/binary/BinarySessionExecution.ts +++ b/src/application/binary/BinarySessionExecution.ts @@ -13,7 +13,10 @@ import type { AnalysisOperation, CapabilityDescriptor, } from "../AnalysisProvider.js"; -import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js"; +import { + isSnapshotCacheable, + requiresNativeSourceAdmissionBeforeReplay, +} from "./AnalysisSnapshotCache.js"; import type { SessionProviderRoute } from "./SessionProviderRouter.js"; interface ActiveExecutionBinding { @@ -35,6 +38,7 @@ interface PrepareSessionExecutionInput { readonly active: ActiveExecutionBinding | undefined; readonly operation: AnalysisOperation; readonly parameters: Readonly>; + readonly nativeSourceAdmitted: boolean; readonly unboundOperationError: ( operation: AnalysisOperation, route: SessionProviderRoute, @@ -55,6 +59,7 @@ export const prepareSessionExecution = ( active, operation, parameters, + nativeSourceAdmitted, unboundOperationError, lookupSnapshot, } = input; @@ -79,9 +84,12 @@ export const prepareSessionExecution = ( const cacheable = profile !== undefined && isSnapshotCacheable(operation, capability, parameters); - const cached = cacheable - ? lookupSnapshot(active.target, profile, operation, parameters) - : undefined; + const cached = + cacheable && + (!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) || + nativeSourceAdmitted) + ? lookupSnapshot(active.target, profile, operation, parameters) + : undefined; return ok({ active, capability, profile, cacheable, cached }); }; diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 8d810e671..cd5f6be4a 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -92,9 +92,7 @@ export const resolveSessionTarget = async ( input: ResolveSessionTargetInput, ): Promise> => { const { router, current, target, options } = input; - const sameTarget = - current?.target.path === target.path && - snapshotMatchesTarget(snapshotTarget(current.target), target); + const sameTarget = sameSessionTarget(current?.target, target); const resolvedRoute = sameTarget && options.providerId === undefined && current !== undefined ? ({ ok: true, value: current.route } as const) @@ -113,9 +111,7 @@ export const validateResolvedSessionTarget = ( }, ): Result => { const { current, target, route, options, stagedSnapshotMatches } = input; - const sameTarget = - current?.target.path === target.path && - snapshotMatchesTarget(snapshotTarget(current.target), target); + const sameTarget = sameSessionTarget(current?.target, target); const snapshotError = validateSnapshot({ snapshot: options.snapshot, target, @@ -127,6 +123,16 @@ export const validateResolvedSessionTarget = ( return { ok: true, value: { target, route, sameTarget } }; }; +const sameSessionTarget = ( + current: BinaryTarget | undefined, + target: BinaryTarget, +): boolean => + current !== undefined && + current.path === target.path && + // Native admission is bound to the requested path, not just its realpath. + (current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) && + snapshotMatchesTarget(snapshotTarget(current), target); + interface ValidateSnapshotInput { readonly snapshot: AnalysisSnapshot | undefined; readonly target: BinaryTarget; diff --git a/src/contracts/toolOutputReferences.test.ts b/src/contracts/toolOutputReferences.test.ts index cda49f1d5..1cd890bf3 100644 --- a/src/contracts/toolOutputReferences.test.ts +++ b/src/contracts/toolOutputReferences.test.ts @@ -6,6 +6,34 @@ import { z } from "zod"; import { toolContract } from "./toolContracts.js"; import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js"; +import { createEvidence } from "../domain/evidence.js"; + +it.each(["draft-2020-12", "draft-07"] as const)( + "advertises the raw-only 6502 Evidence subject constraint in %s", + (target) => { + const valid = createEvidence( + { + path: "/fixture/image.bin", + sha256: "a".repeat(64), + format: "raw-binary", + architecture: "6502", + }, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "address_name", parameters: {}, result: "entry" }, + ); + const advertised = toolOutputSchemaWithMetadata( + toolContract("address_name"), + )["~standard"].jsonSchema.output({ target }); + const validate = new AjvJsonSchemaValidator().getValidator(advertised); + expect(validate(valid).valid).toBe(true); + expect( + validate({ + ...valid, + subject: { ...valid.subject, format: "pe" }, + }).valid, + ).toBe(false); + }, +); it("keeps dialect-specific validators distinct after JSON schema serialization", () => { const contract = { diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts index dcf7ad033..76294e2db 100644 --- a/src/contracts/toolOutputSchemaGroups.ts +++ b/src/contracts/toolOutputSchemaGroups.ts @@ -444,11 +444,11 @@ const lifecycleTargetFields = { path: z.string(), kind: targetKindSchema, sha256: z.string().regex(/^[a-f0-9]{64}$/u), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), }; const detectedLifecycleTargetSchema = z.object({ ...lifecycleTargetFields, format: targetFormatSchema.exclude(["raw-binary"]), + architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(), }); const rawLifecycleTargetSchema = z.object({ ...lifecycleTargetFields, diff --git a/src/domain/analysisSnapshot.test.ts b/src/domain/analysisSnapshot.test.ts index ee3920be5..d83ddac30 100644 --- a/src/domain/analysisSnapshot.test.ts +++ b/src/domain/analysisSnapshot.test.ts @@ -52,7 +52,7 @@ const snapshotWithHistoricalEvidence = () => { }; return { current, - snapshot: { + snapshot: analysisSnapshotSchema.parse({ target, binding, entries: [entry], @@ -66,11 +66,32 @@ const snapshotWithHistoricalEvidence = () => { result: { summary: "unprofiled" }, }), ]), - } satisfies AnalysisSnapshot, + }), }; }; describe("analysis snapshot target identity", () => { + it("keeps 6502 confined to executable raw snapshots", () => { + const target = { + sha256: ANALYSIS_SNAPSHOT_TARGET.sha256, + kind: "executable", + architecture: "6502", + }; + expect( + analysisSnapshotSchema.shape.target.safeParse({ + ...target, + format: "pe", + }).success, + ).toBe(false); + expect( + analysisSnapshotSchema.shape.target.safeParse({ + ...target, + format: "raw-binary", + entry_point: "0x8001", + }).success, + ).toBe(true); + }); + it("partitions raw snapshots and query identities by the canonical entry", () => { const target: BinaryTarget = { path: "/fixture/image.bin", @@ -120,6 +141,48 @@ describe("analysis snapshot target identity", () => { }); }); +describe("analysis snapshot nested subject contract", () => { + it("rejects raw-only 6502 in nested detected execution subjects", () => { + const { snapshot } = snapshotWithHistoricalEvidence(); + const entry = snapshot.entries[0]; + if (entry === undefined) throw new Error("Fixture has no snapshot entry"); + const malformedSubject = { + ...entry.execution.subject, + format: "pe", + architecture: "6502", + }; + expect( + analysisSnapshotSchema.safeParse({ + ...snapshot, + entries: [ + { + ...entry, + execution: { + ...entry.execution, + subject: malformedSubject, + }, + }, + ], + }).success, + ).toBe(false); + expect( + analysisSnapshotSchema.safeParse({ + ...snapshot, + workflow_entries: [ + { + ...entry, + execution: { + ...entry.execution, + analysis_profile: snapshot.binding.analysis_profile, + subject: malformedSubject, + }, + }, + ], + }).success, + ).toBe(false); + }); +}); + describe("analysis snapshot contract", () => { it.each([ ["dos-mz", "x86"], diff --git a/src/domain/analysisSnapshot.ts b/src/domain/analysisSnapshot.ts index 57e876653..4e1af5fa8 100644 --- a/src/domain/analysisSnapshot.ts +++ b/src/domain/analysisSnapshot.ts @@ -41,7 +41,10 @@ import { digestSchema } from "./../domain/digests.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -import { rawBinaryEntryPointSchema } from "./rawBinary.js"; +import { + binaryArchitectureSchema, + rawBinaryEntryPointSchema, +} from "./rawBinary.js"; import { relationshipCoverageSchema, type RelationshipCoverage, @@ -73,20 +76,30 @@ const formatSchema = z.enum([ "source-map", ]); const kindSchema = z.enum(["executable", "database", "archive", "artifact"]); -const subjectSchema = z.object({ +const subjectBaseSchema = z.object({ path: localPathStringSchema, sha256: digestSchema, - format: z.enum([ - ...formatSchema.options, - "hopper", - "directory", - "file", - "unknown", - "javascript-bundle", - "entitlements", - ]), - architecture: architectureSchema.nullable(), }); +const subjectFormatSchema = z.enum([ + ...formatSchema.options, + "hopper", + "directory", + "file", + "unknown", + "javascript-bundle", + "entitlements", +]); +// Snapshot entries retain the same raw-only architecture boundary as targets. +const subjectSchema = z.discriminatedUnion("format", [ + subjectBaseSchema.extend({ + format: subjectFormatSchema.exclude(["raw-binary"]), + architecture: architectureSchema.exclude(["6502"]).nullable(), + }), + subjectBaseSchema.extend({ + format: z.literal("raw-binary"), + architecture: architectureSchema.nullable(), + }), +]); const targetIdentitySchema = z.object({ sha256: digestSchema, kind: kindSchema, @@ -97,9 +110,12 @@ const targetSchema = z.discriminatedUnion("format", [ format: z.enum( formatSchema.options.filter((format) => format !== "raw-binary"), ), + architecture: architectureSchema.exclude(["6502"]).nullable(), }), targetIdentitySchema.extend({ format: z.literal("raw-binary"), + kind: z.literal("executable"), + architecture: binaryArchitectureSchema, entry_point: rawBinaryEntryPointSchema, }), ]); @@ -332,18 +348,21 @@ const canonicalJson = (value: unknown): string => { }; /** Project a binary into the path-free identity used for cache invalidation. */ -export const snapshotTarget = ( - target: BinaryTarget, -): AnalysisSnapshotTarget => { - const identity = { - sha256: target.sha256, - kind: target.kind, - architecture: target.architecture ?? null, - }; - return target.format === "raw-binary" - ? { ...identity, format: target.format, entry_point: target.entryPoint } - : { ...identity, format: target.format }; -}; +export const snapshotTarget = (target: BinaryTarget): AnalysisSnapshotTarget => + target.format === "raw-binary" + ? { + sha256: target.sha256, + kind: target.kind, + architecture: target.architecture, + format: target.format, + entry_point: target.entryPoint, + } + : { + sha256: target.sha256, + kind: target.kind, + architecture: target.architecture ?? null, + format: target.format, + }; /** Build the immutable provider/profile binding used by a cached snapshot. */ export const snapshotBinding = ( @@ -475,10 +494,10 @@ export const createAnalysisSnapshotWorkflowEntry = (input: { subject: execution.subject === null ? null - : { + : subjectSchema.parse({ ...execution.subject, architecture: execution.subject.architecture ?? null, - }, + }), }, }; }; @@ -538,10 +557,10 @@ export const createAnalysisSnapshotEntry = (input: { subject: execution.subject === null ? null - : { + : subjectSchema.parse({ ...execution.subject, architecture: execution.subject.architecture ?? null, - }, + }), }, }; }; diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index 99bc6dd24..94828ee6e 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -351,7 +351,9 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => { return undefined; }; -const elfArchitecture = (machine: number): BinaryArchitecture | undefined => { +const elfArchitecture = ( + machine: number, +): Exclude | undefined => { switch (machine) { case 3: return "x86"; diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts index ab5046549..99ad8487f 100644 --- a/src/domain/binaryTargetTypes.ts +++ b/src/domain/binaryTargetTypes.ts @@ -1,12 +1,13 @@ import type { ZipPackageFormat } from "./zipPackageFormat.js"; -/** Provider-neutral CPU families detected from supported executable headers. */ +/** Provider-neutral CPU families detected from headers or explicitly selected raw loads. */ export const BINARY_ARCHITECTURES = [ "x86", "x86_64", "arm", "arm64", "mips", + "6502", "riscv", ] as const; export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number]; @@ -40,7 +41,7 @@ export const isElfMetadataArchitecture = ( /** Recognized family with no ELF metadata member; the header alone is the fact. */ export type HeaderArchitecture = Exclude< BinaryArchitecture, - ElfMetadataArchitecture + ElfMetadataArchitecture | "6502" >; /** Raw ELF facts; family recognition does not establish ISA or ABI support. */ @@ -144,8 +145,17 @@ export type BinaryTarget = readonly executableRole?: never; readonly managed?: never; }) - | (ExecutableTarget & { + | (BinaryTargetIdentity & { + readonly kind: "executable"; readonly format: "raw-binary"; + readonly architecture: Exclude< + BinaryArchitecture, + ElfMetadataArchitecture + >; + readonly availableArchitectures: readonly Exclude< + BinaryArchitecture, + ElfMetadataArchitecture + >[]; readonly entryPoint: string; readonly entryPointSource: string; readonly executableRole?: never; diff --git a/src/domain/evidence.test.ts b/src/domain/evidence.test.ts index 5cdf30a27..be923e3a6 100644 --- a/src/domain/evidence.test.ts +++ b/src/domain/evidence.test.ts @@ -19,6 +19,33 @@ const TARGET: BinaryTarget = { const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" }; const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" }); +it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => { + const selected = { + path: TARGET.path, + sha256: TARGET.sha256, + format: "raw-binary" as const, + architecture: "6502" as const, + }; + expect( + createEvidence(selected, PROVIDER, { + operation: "inspect", + parameters: {}, + result: null, + }).subject, + ).toMatchObject({ format: "raw-binary", architecture: "6502" }); + const malformed = { + ...selected, + format: "pe" as const, + }; + expect(() => + createEvidence(malformed, PROVIDER, { + operation: "inspect", + parameters: {}, + result: null, + }), + ).toThrow(); +}); + it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => { const observation = { operation: "procedure_callees", diff --git a/src/domain/evidence.ts b/src/domain/evidence.ts index 8a450f478..49cb2a229 100644 --- a/src/domain/evidence.ts +++ b/src/domain/evidence.ts @@ -38,42 +38,52 @@ export const providerSchema = z.strictObject({ name: z.string().min(1), version: z.string().nullable(), }); -const subjectSchema = z.strictObject({ +const subjectFormatSchema = z.enum([ + "hopper", + "analysis-database", + "mach-o", + "elf", + "pe", + "dos-mz", + "dos-com", + "raw-binary", + "zip", + "ipa", + "apk", + "msix", + "appx", + "hap", + "hsp", + "app-pack", + "asar", + "dmg", + "pkg", + "plist", + "javascript", + "source-map", + "directory", + "file", + "unknown", + "mach-o-universal", + "javascript-bundle", + "entitlements", +]); +const subjectBaseSchema = z.strictObject({ name: z.string().min(1), digest: z.strictObject({ sha256: digestSchema }), - format: z.enum([ - "hopper", - "analysis-database", - "mach-o", - "elf", - "pe", - "dos-mz", - "dos-com", - "raw-binary", - "zip", - "ipa", - "apk", - "msix", - "appx", - "hap", - "hsp", - "app-pack", - "asar", - "dmg", - "pkg", - "plist", - "javascript", - "source-map", - "directory", - "file", - "unknown", - "mach-o-universal", - "javascript-bundle", - "entitlements", - ]), - architecture: z.enum(BINARY_ARCHITECTURES).nullable(), local_path: nulFreeStringSchema, }); +// The format owns the architecture claim, including nested Evidence subjects. +const subjectSchema = z.discriminatedUnion("format", [ + subjectBaseSchema.extend({ + format: subjectFormatSchema.exclude(["raw-binary"]), + architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(), + }), + subjectBaseSchema.extend({ + format: z.literal("raw-binary"), + architecture: z.enum(BINARY_ARCHITECTURES).nullable(), + }), +]); /** Source location attached to an evidence observation. */ /** Source-location schema shared by evidence-bearing persistence formats. */ export const evidenceLocationSchema = z.discriminatedUnion("kind", [ @@ -186,7 +196,7 @@ const immutableResultSchema = z }); export type EvidenceLocation = z.infer; -/** Minimal immutable local artifact identity accepted by Evidence. */ +/** Candidate local artifact identity validated by the Evidence subject schema. */ export interface EvidenceSubjectTarget { readonly path: string; readonly sha256: string; diff --git a/src/domain/evidenceBundle.ts b/src/domain/evidenceBundle.ts index 7e6fd2b3b..a70969b8e 100644 --- a/src/domain/evidenceBundle.ts +++ b/src/domain/evidenceBundle.ts @@ -16,14 +16,17 @@ import { } from "./residualUnknown.js"; import { freezeJsonSnapshot } from "./immutableJson.js"; -const artifactManifestSchema = evidenceSchema.shape.subject - .unwrap() - .pick({ - digest: true, - format: true, - architecture: true, - }) - .strict(); +const [detectedSubjectSchema, rawSubjectSchema] = + evidenceSchema.shape.subject.unwrap().options; +const manifestFields = { + digest: true, + format: true, + architecture: true, +} as const; +const artifactManifestSchema = z.discriminatedUnion("format", [ + detectedSubjectSchema.pick(manifestFields), + rawSubjectSchema.pick(manifestFields), +]); const providerManifestSchema = providerSchema; const environmentManifestSchema = evidenceSchema.shape.environment.unwrap(); const scenarioManifestSchema = z.strictObject({ @@ -85,11 +88,11 @@ export const createEvidenceBundle = ( subject === null ? [] : [ - { + artifactManifestSchema.parse({ digest: subject.digest, format: subject.format, architecture: subject.architecture, - }, + }), ], ), ), diff --git a/src/domain/native/nativeLoadImage.ts b/src/domain/native/nativeLoadImage.ts index 6a3be4421..a4efc2847 100644 --- a/src/domain/native/nativeLoadImage.ts +++ b/src/domain/native/nativeLoadImage.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js"; import { jsonValueSchema } from "../jsonValue.js"; +import { binaryArchitectureSchema } from "../rawBinary.js"; const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); const digest = z.string().regex(/^[a-f0-9]{64}$/u); @@ -112,7 +113,7 @@ export const nativeLoadImageSchema = z.union([ status: z.enum(["verified", "mismatch"]), format: z.literal("raw-binary"), target_sha256: digest, - architecture: z.enum(["x86", "x86_64", "arm", "arm64"]), + architecture: binaryArchitectureSchema, language_id: z.string().min(1), compiler_spec_id: z.string().min(1), base_address: address, diff --git a/src/domain/rawBinary.test.ts b/src/domain/rawBinary.test.ts index f22e5fced..5cb6ed129 100644 --- a/src/domain/rawBinary.test.ts +++ b/src/domain/rawBinary.test.ts @@ -39,6 +39,44 @@ describe("raw-binary entry addresses", () => { }); describe("raw-binary admission", () => { + it("admits byte-aligned 6502 images up to the 16-bit address boundary", () => { + for (const [entryPoint, fileBytes] of [ + ["0x8001", 8], + ["0xffff", 1], + ["0x0", 65536], + ] as const) { + const result = admitRawBinary({ + architecture: "6502", + entryPoint, + fileBytes, + }); + if (!result.ok) throw new Error(result.error.reason); + expect(result.value).toEqual({ + architecture: "6502", + entryPoint, + entryPointSource: entryPoint, + }); + } + }); + it.each([ + ["0x10000", 1], + ["0xffff", 2], + ["0x0", 65537], + ])( + "rejects a 6502 mapping at %s with %i bytes that exceeds 16 bits", + (entryPoint, fileBytes) => { + const result = admitRawBinary({ + architecture: "6502", + entryPoint, + fileBytes, + }); + if (result.ok) throw new Error("expected 16-bit overflow rejection"); + expect(result.error).toEqual({ + reason: `raw-binary mapping from ${entryPoint} must fit in the 6502 address width`, + constraint: "load_selection_rejected", + }); + }, + ); it("admits a little-endian ARM image mapped at the caller entry", () => { expect( admitRawBinary({ diff --git a/src/domain/rawBinary.ts b/src/domain/rawBinary.ts index 94fdf6ce7..22d94f4d6 100644 --- a/src/domain/rawBinary.ts +++ b/src/domain/rawBinary.ts @@ -13,6 +13,7 @@ export const BINARY_ARCHITECTURES = [ "x86_64", "arm", "arm64", + "6502", ] as const satisfies readonly BinaryArchitecture[]; /** Provider-neutral CPU family for a caller-supplied raw-binary load. */ @@ -58,7 +59,7 @@ export const validateRawBinaryLength = ( /** Admitted headerless load identity used by target resolution and profiles. */ export interface RawBinaryAdmission { - readonly architecture: HeaderArchitecture; + readonly architecture: HeaderArchitecture | "6502"; readonly entryPoint: string; readonly entryPointSource: string; } @@ -74,9 +75,11 @@ export interface RawBinaryAdmissionRejection { } const addressWidth = (architecture: BinaryArchitecture): bigint => - architecture === "x86" || architecture === "arm" - ? 0xffffffffn - : 0xffffffffffffffffn; + architecture === "6502" + ? 0xffffn + : architecture === "x86" || architecture === "arm" + ? 0xffffffffn + : 0xffffffffffffffffn; /** Validate architecture, entry, and that the mapped image fits the CPU width. */ export const admitRawBinary = (input: { @@ -88,7 +91,7 @@ export const admitRawBinary = (input: { if (!architecture.success) return err({ reason: - "raw-binary format requires architecture (x86, x86_64, arm, or arm64)", + "raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)", constraint: "load_selection_rejected", }); if (typeof input.entryPoint !== "string") diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index fd7cdff11..8cc5f2807 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -136,7 +136,7 @@ export const resolveGhidraAnalysisProfile = async ( installation.platform === "win32" ? "official-headless-script-v1" : "inspected-jvm-launch-support-v1", - load_image_evidence: "independent-raw-mapping-context-v2", + load_image_evidence: "independent-raw-mapping-context-v4", jump_table_evidence: "typed-case-default-blocks-v1", decompiler_jump_loads: true, loader: "BinaryLoader", diff --git a/src/ghidra/GhidraLoadImageValues.ts b/src/ghidra/GhidraLoadImageValues.ts index 1c80bb635..f06f4fdaa 100644 --- a/src/ghidra/GhidraLoadImageValues.ts +++ b/src/ghidra/GhidraLoadImageValues.ts @@ -361,7 +361,10 @@ export const attestGhidraRawBinaryLoadImage = ( }); }; const source = observations.source_files[0]; - const mapping = observations.mappings[0]; + const fileMappings = observations.mappings.filter( + (item) => item.source_file_index !== null, + ); + const mapping = fileMappings[0]; check("executable_format", "Raw Binary", observations.executable_format); check("language_id", language.languageId, observations.language_id); check( @@ -371,11 +374,34 @@ export const attestGhidraRawBinaryLoadImage = ( ); // BinaryLoader's block base is independent of the Program image base. check("image_base", "0x0", observations.image_base); + check( + "default_address_space", + language.defaultAddressSpace, + observations.default_address_space, + ); check("source_file_count", 1, observations.source_files.length); check("source_file_size", snapshot.bytes, source?.size ?? null); check("original_file_sha256", target.sha256, source?.original_sha256 ?? null); check("modified_file_sha256", target.sha256, source?.modified_sha256 ?? null); - check("mapping_count", 1, observations.mappings.length); + // Language-defined uninitialized zero page/stack blocks are observations, not file bytes. + check("mapping_count", 1, fileMappings.length); + check( + "initialized_unbacked_mappings", + 0, + observations.mappings.filter( + (item) => item.initialized && item.source_file_index === null, + ).length, + ); + check("mapping.source_file_index", 0, mapping?.source_file_index ?? null); + // Matching bytes alone do not establish the admitted default-space load. + check("mapping.initialized", true, mapping?.initialized ?? null); + check("mapping.loaded", true, mapping?.loaded ?? null); + check("mapping.overlay", false, mapping?.overlay ?? null); + check( + "mapping.address_space", + observations.default_address_space, + mapping?.address_space ?? null, + ); check( "mapping.file_offset", 0, @@ -427,8 +453,9 @@ export const attestGhidraRawBinaryLoadImage = ( checks, limitations: [ "Raw binary is a headerless interpretation explicitly selected by the caller, not format identification from file bytes.", - "Verification covers complete original/modified source identity, mapped file bytes, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.", + "Verification covers complete original/modified source identity, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.", "Ghidra may report additional language-defined external entries outside the mapped image. They are preserved as observations; verification requires the caller-supplied entry and does not attest other entries.", + "Language-defined uninitialized memory, such as 6502 zero page and stack, is preserved as observations but is not attested as file-backed bytes.", "Reset vectors, memory-mapped I/O, big-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.", "Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.", ], diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 3979222d9..a41bf096f 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js"; import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js"; import { @@ -30,6 +30,8 @@ import { err, ok } from "../domain/result.js"; import type { Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; import { silentLogger } from "../logger.js"; +import * as WindowsNativeLoader from "../windows/WindowsNativeLoader.js"; +import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; @@ -190,6 +192,36 @@ describe("Ghidra provider", () => { }); describe("Ghidra raw-binary profile", () => { + it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => { + const ghidra = provider({ ...installationHost(), platform: "win32" }); + const target: BinaryTarget = { + path: "/tmp/6502.bin", + sha256: "a".repeat(64), + kind: "executable", + format: "raw-binary", + architecture: "6502", + availableArchitectures: ["6502"], + entryPoint: "0x8001", + entryPointSource: "0x008001", + }; + expect(ghidra.inspectTargetSupport(target).status).toBe("supported"); + const resolved = await ghidra.resolveAnalysisProfile(target); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters).toMatchObject({ + architecture: "6502", + loader: "BinaryLoader", + language_id: "6502:LE:16:default", + compiler_spec_id: "default", + entry_point: "0x8001", + language_selection: "caller-supplied-v1", + load_image_evidence: "independent-raw-mapping-context-v4", + }); + expect( + ghidra + .capabilities() + .some(({ available, effects }) => available && effects.mutatesArtifact), + ).toBe(false); + }); it("commits BinaryLoader language and caller-supplied entry", async () => { const ghidra = provider(); const target: BinaryTarget = { @@ -324,6 +356,13 @@ describe("Ghidra platform support", () => { }); it("keeps Windows P0 unavailable until native isolation authority exists", () => { + const native = vi + .spyOn(WindowsNativeLoader, "systemWindowsNativeAuthority") + .mockReturnValue({ + available: false, + reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON, + }); + onTestFinished(() => native.mockRestore()); const ghidra = provider({ ...installationHost(), platform: "win32" }); const nativeApplication = peTarget("x86_64"); @@ -812,11 +851,22 @@ describe("Ghidra relationship coverage", () => { }); }); -describe("Ghidra measured load-image projection", () => { - it.each(["verified", "changed-snapshot", "cancelled"] as const)( +describe("Ghidra measured raw load-image projection", () => { + it.each([ + "verified", + "mapping-mismatch", + "changed-snapshot", + "cancelled", + ] as const)( "streams raw snapshot identity and preserves %s outcomes", async (state) => { const fixture = fixtureRawBinaryLoadImage(); + if (state === "mapping-mismatch") { + const mapping = fixture.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.overlay = true; + } const controller = new AbortController(); const signals: (AbortSignal | undefined)[] = []; const ghidra = provider(installationHost(), () => ({ @@ -872,12 +922,23 @@ describe("Ghidra measured load-image projection", () => { { signal: controller.signal }, ); expect(signals).toEqual([controller.signal]); - if (state === "verified") { + if (state === "verified" || state === "mapping-mismatch") { if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ - status: "verified", + status: state === "verified" ? "verified" : "mismatch", observations: fixture.observation, }); + if (state === "mapping-mismatch") { + expect(result.value.result).toMatchObject({ + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "mapping.overlay", + observed: true, + matched: false, + }), + ]), + }); + } } else { expect(result).toMatchObject({ ok: false, @@ -894,7 +955,9 @@ describe("Ghidra measured load-image projection", () => { } }, ); +}); +describe("Ghidra measured DOS load-image projection", () => { it.each([ "verified", "mismatch", diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts index c48f58957..0a1cde0f0 100644 --- a/src/ghidra/GhidraProvider.ts +++ b/src/ghidra/GhidraProvider.ts @@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([ "arm", "arm64", "mips", + "6502", "riscv", ]); @@ -184,7 +185,7 @@ export class GhidraProvider implements AnalysisProviderCandidate { status: "unsupported", code: "architecture_unsupported", reason: - "Ghidra v1 requires a supported x86, x86_64, arm, arm64, or MIPS target profile.", + "Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, or raw 6502 target profile.", diagnostics, }; return { @@ -304,11 +305,15 @@ const inspectWindowsP0TargetSupport = ( target: BinaryTarget, diagnostics: Readonly>, ): ProviderTargetSupport => { + // The raw 6502 lane uses the same immutable snapshot and native ownership boundary. + if (target.format === "raw-binary" && target.architecture === "6502") + return { status: "supported", code: null, reason: null, diagnostics }; if (target.format !== "pe") return { status: "unsupported", code: "target_format_unsupported", - reason: "Windows Ghidra P0 accepts PE targets only.", + reason: + "Windows Ghidra P0 accepts native PE and explicitly selected raw 6502 targets only.", diagnostics, }; if (target.architecture !== "x86" && target.architecture !== "x86_64") diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index ea863e8a7..68c3045ce 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -33,7 +33,7 @@ export const healthLimitations = Object.freeze([ /** Additional limitations applied to the experimental Windows x64 P0 boundary. */ export const windowsP0Limitations = Object.freeze([ - "Windows Ghidra P0 accepts approved native x86 and x86-64 PE applications and DLLs; managed, hostile, sensitive, and mutable-path targets are unsupported.", + "Windows Ghidra P0 accepts native x86 and x86-64 PE applications and DLLs, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.", "The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.", "Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.", ]); diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 5911985df..3a15dbc4e 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -516,7 +516,7 @@ export const createGhidraProviderClient = (input: { ...(target.format === "raw-binary" ? [ "Headerless raw-binary analysis uses the caller-supplied architecture and entry address; file offset 0 is mapped at that address. Headers are not discovered from file bytes.", - "Little-endian generic Ghidra languages are used for x86, x86_64, arm, and arm64. Big-endian, Thumb-only, Cortex-M, and other processor variants are unsupported in this increment.", + "Little-endian generic Ghidra languages are used for x86, x86_64, arm, arm64, and NMOS 6502. Big-endian, Thumb-only, Cortex-M, 65C02, 65816, and other processor variants are unsupported in this increment.", "Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.", ] : []), diff --git a/src/ghidra/GhidraRawBinaryLanguage.ts b/src/ghidra/GhidraRawBinaryLanguage.ts index ea024bdba..e9046975d 100644 --- a/src/ghidra/GhidraRawBinaryLanguage.ts +++ b/src/ghidra/GhidraRawBinaryLanguage.ts @@ -1,28 +1,51 @@ -import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js"; +import type { RawBinaryAdmission } from "../domain/rawBinary.js"; /** Bound Ghidra language for a caller-supplied raw-binary architecture. */ export interface GhidraRawBinaryLanguage { readonly languageId: string; readonly compilerSpecId: "default" | "gcc"; + readonly defaultAddressSpace: "ram" | "RAM"; } /** - * Map the four caller-visible CPU families to little-endian generic Ghidra + * Map caller-visible CPU families to little-endian generic Ghidra * languages with a supported generic compiler specification. These are * analysis conventions, not evidence of the target's original compiler. * Big-endian, Thumb-only, and Cortex-M variants are out of scope. */ export const ghidraRawBinaryLanguage = ( - architecture: HeaderArchitecture, + architecture: RawBinaryAdmission["architecture"], ): GhidraRawBinaryLanguage => { switch (architecture) { + case "6502": + return { + languageId: "6502:LE:16:default", + compilerSpecId: "default", + defaultAddressSpace: "RAM", + }; case "x86": - return { languageId: "x86:LE:32:default", compilerSpecId: "gcc" }; + return { + languageId: "x86:LE:32:default", + compilerSpecId: "gcc", + defaultAddressSpace: "ram", + }; case "x86_64": - return { languageId: "x86:LE:64:default", compilerSpecId: "gcc" }; + return { + languageId: "x86:LE:64:default", + compilerSpecId: "gcc", + defaultAddressSpace: "ram", + }; case "arm": - return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" }; + return { + languageId: "ARM:LE:32:v7", + compilerSpecId: "default", + defaultAddressSpace: "ram", + }; case "arm64": - return { languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default" }; + return { + languageId: "AARCH64:LE:64:v8A", + compilerSpecId: "default", + defaultAddressSpace: "ram", + }; } }; diff --git a/src/ghidra/GhidraRawBinaryLoadImage.test.ts b/src/ghidra/GhidraRawBinaryLoadImage.test.ts index 2fc11502e..d09b3a504 100644 --- a/src/ghidra/GhidraRawBinaryLoadImage.test.ts +++ b/src/ghidra/GhidraRawBinaryLoadImage.test.ts @@ -9,23 +9,161 @@ describe("raw-binary Ghidra language binding", () => { expect(ghidraRawBinaryLanguage("x86")).toEqual({ languageId: "x86:LE:32:default", compilerSpecId: "gcc", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("x86_64")).toEqual({ languageId: "x86:LE:64:default", compilerSpecId: "gcc", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("arm")).toEqual({ languageId: "ARM:LE:32:v7", compilerSpecId: "default", + defaultAddressSpace: "ram", }); expect(ghidraRawBinaryLanguage("arm64")).toEqual({ languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default", + defaultAddressSpace: "ram", + }); + }); +}); + +describe("raw-binary mapped-block state", () => { + it.each(["RAM", "ram"])( + "requires the bound 6502 address space for %s observations", + (space) => { + const f = fixtureRawBinaryLoadImage(); + const target = { + ...f.target, + architecture: "6502" as const, + availableArchitectures: ["6502" as const], + entryPoint: "0x8001", + entryPointSource: "0x8001", + }; + f.observation.language_id = "6502:LE:16:default"; + f.observation.default_address_space = space; + f.observation.entry_points = [target.entryPoint]; + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.start = target.entryPoint; + mapping.end = "0x8004"; + mapping.address_space = space; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: space === "RAM" ? "verified" : "mismatch", + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "default_address_space", + expected: "RAM", + observed: space, + matched: space === "RAM", + }), + ]), + }, + }); + }, + ); + it.each([ + ["initialized", false], + ["loaded", false], + ["overlay", true], + ["address_space", "overlay"], + ] as const)( + "retains a mismatch when the file mapping declares %s=%s", + (property, value) => { + const f = fixtureRawBinaryLoadImage(); + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + Object.assign(mapping, { [property]: value }); + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + observations: f.observation, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: `mapping.${property}`, + matched: false, + observed: value, + }), + ]), + }, + }); + }, + ); + it("does not verify a foreign default address space even when its mapping agrees", () => { + const f = fixtureRawBinaryLoadImage(); + f.observation.default_address_space = "foreign"; + const mapping = f.observation.mappings[0]; + if (mapping === undefined) + throw new Error("Missing source mapping fixture"); + mapping.address_space = "foreign"; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + observations: f.observation, + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "default_address_space", + matched: false, + observed: "foreign", + }), + ]), + }, }); }); }); describe("raw-binary independent load-image checks", () => { + it("retains uninitialized language memory without treating it as file-backed bytes", () => { + const f = fixtureRawBinaryLoadImage(); + const zeroPage = { + block: "ZERO_PAGE", + start: "0x0", + end: "0xff", + address_space: "ram", + initialized: false, + loaded: true, + overlay: false, + length: 256, + source_file_index: null, + file_offset: null, + sha256: null, + }; + f.observation.mappings.unshift(zeroPage); + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { status: "verified", observations: f.observation }, + }); + zeroPage.initialized = true; + expect( + attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation), + ).toMatchObject({ + ok: true, + value: { + status: "mismatch", + checks: expect.arrayContaining([ + expect.objectContaining({ + name: "initialized_unbacked_mappings", + matched: false, + }), + ]), + }, + }); + }); it("verifies mapped bytes at the caller-supplied entry", () => { const f = fixtureRawBinaryLoadImage(); const result = attestGhidraRawBinaryLoadImage( diff --git a/src/ghidra/GhidraRiscvProfile.test.ts b/src/ghidra/GhidraRiscvProfile.test.ts index 881ba0078..718fe8d46 100644 --- a/src/ghidra/GhidraRiscvProfile.test.ts +++ b/src/ghidra/GhidraRiscvProfile.test.ts @@ -1,6 +1,6 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, parse, relative } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { parseConfig } from "../config/parseConfig.js"; import type { @@ -92,7 +92,14 @@ const target = (change: Partial = {}): BinaryTarget => ({ let installDir = ""; beforeAll(async () => { - installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-")); + const root = + process.platform === "win32" ? join(process.cwd(), "build") : tmpdir(); + await mkdir(root, { recursive: true }); + installDir = await mkdtemp(join(root, "rea-ghidra-riscv-languages-")); + // The simulated Linux host needs a POSIX absolute path. A current-drive root + // path also reaches this real fixture on Windows without changing admission. + if (process.platform === "win32") + installDir = `/${relative(parse(installDir).root, installDir).replaceAll("\\", "/")}`; const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages"); await mkdir(languages, { recursive: true }); await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS); diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts index 299862a97..a4848533f 100644 --- a/src/hopper/HopperAnalysisProfile.ts +++ b/src/hopper/HopperAnalysisProfile.ts @@ -123,6 +123,8 @@ export const hopperLoaderArgsForTarget = ( if (target.kind === "database") return ok([]); if (target.kind !== "executable") return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); + if (target.format === "raw-binary") + return err(new ProviderAdapterError("hopper", "resolve_analysis_profile")); const architecture = target.architecture; if (isElfMetadataArchitecture(architecture)) return err( @@ -155,7 +157,6 @@ export const hopperLoaderArgsForTarget = ( return ok(["-l", "WinPE", flag]); case "dos-mz": case "dos-com": - case "raw-binary": return err( new ProviderAdapterError("hopper", "resolve_analysis_profile"), ); diff --git a/src/hopper/HopperMachOImage.ts b/src/hopper/HopperMachOImage.ts index 20a53105c..ed81ea749 100644 --- a/src/hopper/HopperMachOImage.ts +++ b/src/hopper/HopperMachOImage.ts @@ -2,11 +2,7 @@ import { createHash } from "node:crypto"; import { open, type FileHandle } from "node:fs/promises"; import { z } from "zod"; -import { - isMachOFormat, - isElfMetadataArchitecture, - type BinaryTarget, -} from "../domain/binaryTargetTypes.js"; +import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { @@ -48,10 +44,7 @@ export const resolveHopperMachOImage = async ( ), ); if (signal?.aborted) return err(new AnalysisCancelledError("open_binary")); - if ( - !isMachOFormat(target.format) || - isElfMetadataArchitecture(target.architecture) - ) + if (target.format !== "mach-o" && target.format !== "mach-o-universal") return invalid("Hopper FAT64 image preparation requires a Mach-O target"); let file: FileHandle; try { diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index 66fa21ba3..21ea99a9c 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -180,14 +180,17 @@ describe("trace-dylib-resolution CLI", () => { // A root below a symlinked directory that points outside the bundle. const outside = await createTestTempDirectory("rea-dylib-outside-"); await writeFiles(outside, { Tool: machoImage({}) }); - await symlink(outside, join(app, "Contents/Linked")); + await symlink(outside, join(app, "Contents/Linked"), "dir"); for (const [root, reason, message] of [ ["../outside", "invalid_format", "Use a normalized path"], ["Contents/Info.plist", "invalid_value", "is not a Mach-O image"], [ "Contents/Linked/Tool", "invalid_value", - "resolves outside the analyzed root", + // Windows link text is outside the POSIX dyld path representation. + process.platform === "win32" + ? "is not a regular file in the analyzed root" + : "resolves outside the analyzed root", ], ] as const) { await expectRootInputError(cli, { app, root, reason, message }); @@ -378,7 +381,8 @@ cliTest( }, ); -describe.skipIf(process.getuid?.() === 0)( +// This permission fixture requires POSIX mode bits and a non-root caller. +describe.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "trace-dylib-resolution permissions", () => { cliTest( @@ -510,6 +514,8 @@ cliTest( app, "--root", "Contents/MacOS/App", + "--architecture", + "arm64e", "--json", ], environment: ENVIRONMENT, @@ -518,9 +524,7 @@ cliTest( const trace = dylibResolutionResultSchema.parse( parseEvidence(result.json).normalized_result, ); - expect( - trace.roots.filter(({ architecture }) => architecture === "arm64e"), - ).toHaveLength(2); + expect(trace.roots).toHaveLength(2); const environments = trace.findings .filter(({ kind }) => kind === "dyld-environment-present") .map(({ explanation }) => diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts index 1cff4cac6..f12312d02 100644 --- a/tests/boundary/cli/rawBinaryCli.test.ts +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -38,6 +38,36 @@ cliTest( }, ); +cliTest( + "rejects a 6502 image crossing the 16-bit boundary before provider startup", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-6502-cli-overflow-"); + const path = join(root, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + const result = await cli.run({ + arguments: [ + "inspect", + path, + "--target-format", + "raw-binary", + "--architecture", + "6502", + "--entry-point", + "0xffff", + "--provider", + "ghidra", + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { reason: expect.stringContaining("6502 address width") }, + }); + }, +); + cliTest( "rejects an unaligned AArch64 entry before provider startup", async ({ cli }) => { diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 6687b0ef7..7adf193f4 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -1,5 +1,5 @@ import { parseConfig } from "../../../src/config/parseConfig.js"; -import { writeFile } from "node:fs/promises"; +import { symlink, writeFile } from "node:fs/promises"; import { basename, dirname, join } from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -14,7 +14,7 @@ import type { import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; import { readAnalysisSnapshot, writeAnalysisSnapshot, @@ -40,6 +40,7 @@ import { } from "../../fixtures/binarySession.js"; import { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { CAPABILITIES as GHIDRA_CAPABILITIES, @@ -396,7 +397,85 @@ describe("snapshot output cleanup lifecycle", () => { }); }); +const verifyWindowsSnapshotAdmission = async ( + selectedPath: "direct" | "junction", +): Promise => { + const fixture = await createSnapshotReplayFixture( + "rea-direct-snapshot-junction-", + ); + const rawPath = join(dirname(fixture.path), "image.bin"); + await writeFile(rawPath, Buffer.from("a92a60", "hex")); + const linked = join(dirname(fixture.path), "linked"); + const linkedPath = join(linked, basename(rawPath)); + const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }; + const profile = createAnalysisProfile(identity, { + fixture: true, + }); + const baseProvider = fixture.createProvider({ + identity, + profile, + supportedOperations: ["search_strings"], + }); + let rejectAdmission = false; + const provider: AnalysisProvider = { + ...baseProvider, + createClient: (target, profile, context) => { + const client = baseProvider.createClient(target, profile, context); + return { + ...client, + execute: (operation, parameters, options) => + rejectAdmission && operation === "search_strings" + ? Promise.resolve( + err(new ProviderAdapterError("ghidra", operation)), + ) + : client.execute(operation, parameters, options), + }; + }, + }; + const dependencies = fixture.dependencies(provider); + const arguments_ = { pattern: "fixture", document: "fixture" }; + const options = { + snapshotPath: fixture.snapshotPath, + formatHint: "raw-binary" as const, + architecture: "6502" as const, + entryPoint: "0x8001", + }; + const first = await runDirectAnalysis( + dependencies, + rawPath, + "search_strings", + arguments_, + options, + ); + expect(first).toMatchObject({ operation: "search_strings" }); + expect(fixture.starts).toHaveLength(1); + if (selectedPath === "junction") + await symlink(dirname(fixture.path), linked, "junction"); + rejectAdmission = true; + const second = await runDirectAnalysis( + dependencies, + selectedPath === "junction" ? linkedPath : rawPath, + "search_strings", + arguments_, + options, + ); + expect(second).toMatchObject({ error: "Analysis failed" }); + expect(fixture.starts).toHaveLength(2); + expect( + fixture.calls.filter((call) => call === "search_strings"), + ).toHaveLength(1); +}; + describe("direct analysis composed snapshot replay", () => { + it.skipIf(process.platform !== "win32")( + "does not replay a CLI snapshot through a Windows junction", + () => verifyWindowsSnapshotAdmission("junction"), + ); + it.skipIf(process.platform !== "win32")( + "does not replay a CLI snapshot before native admission on a canonical path", + () => verifyWindowsSnapshotAdmission("direct"), + ); + it.each([ { tool: "search_strings", @@ -516,7 +595,7 @@ describe("Ghidra composed workflow snapshot replay", () => { { tool: "inspect_native_api", arguments: { procedure: "0x1000" } }, { tool: "trace_feature", arguments: { query: "fixture" } }, ] as const)( - "persists and replays $tool with production Ghidra capability policies", + "persists $tool with production Ghidra capability policies", async ({ tool, arguments: parameters }) => { const fixture = await createSnapshotReplayFixture("rea-ghidra-workflow-"); const profile = createAnalysisProfile( @@ -554,8 +633,14 @@ describe("Ghidra composed workflow snapshot replay", () => { { snapshotPath: fixture.snapshotPath }, ); expect(replay).toEqual(first); - expect(fixture.starts).toHaveLength(1); - expect(fixture.calls).toEqual(initialCalls); + if (process.platform === "win32") { + // The first provider call in a new CLI session admits its source. + expect(fixture.starts).toHaveLength(2); + expect(fixture.calls.length).toBeGreaterThan(initialCalls.length); + } else { + expect(fixture.starts).toHaveLength(1); + expect(fixture.calls).toEqual(initialCalls); + } }, ); }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 6b7253b6d..416337097 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -388,7 +388,7 @@ it("produces deterministic ASAR graphs with unpacked native linkage and complete await mkdir(source); await writeJavaScriptArtifactFixture(source); const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); const first = await reconstructJavaScriptArtifact({ input_path: archive, @@ -487,7 +487,7 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent", await mkdir(source); await writeJavaScriptArtifactFixture(source); const archive = join(root, "app.asar"); - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); await rm(join(`${archive}.unpacked`, "native", "addon.node")); const snapshot = await scanArtifactInventory(archive); @@ -515,7 +515,7 @@ it("recurses into filesystem-backed ASAR containers without losing container-rel const resources = join(outer, "resources"); await mkdir(resources, { recursive: true }); await createPackageWithOptions(source, join(resources, "app.asar"), { - unpack: "**/*.node", + unpack: "*.node", }); const result = await reconstructJavaScriptArtifact({ diff --git a/tests/boundary/filesystem/rawBinaryTarget.test.ts b/tests/boundary/filesystem/rawBinaryTarget.test.ts index 5fd171d20..2757f0507 100644 --- a/tests/boundary/filesystem/rawBinaryTarget.test.ts +++ b/tests/boundary/filesystem/rawBinaryTarget.test.ts @@ -1,5 +1,11 @@ import { createHash } from "node:crypto"; -import { readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + realpath, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,6 +13,37 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("raw-binary admission", () => { + it("preserves the requested raw path for Windows reparse admission", async () => { + const directory = await createTestTempDirectory("rea-raw-linked-"); + const actual = join(directory, "actual"); + const linked = join(directory, "linked"); + await mkdir(actual); + const bytes = Buffer.from("a90160", "hex"); + await writeFile(join(actual, "fixture.bin"), bytes); + await symlink( + actual, + linked, + process.platform === "win32" ? "junction" : "dir", + ); + const requested = join(linked, "fixture.bin"); + const canonical = await realpath(requested); + const target = await parseBinaryTarget("linked/fixture.bin", { + cwd: directory, + formatHint: "raw-binary", + architecture: "6502", + entryPoint: "0x8000", + }); + expect(target).toMatchObject({ + ok: true, + value: { + path: canonical, + sourcePath: process.platform === "win32" ? requested : canonical, + sha256: createHash("sha256").update(bytes).digest("hex"), + format: "raw-binary", + architecture: "6502", + }, + }); + }); it("requires explicit architecture and entry even with a misleading suffix", async () => { const directory = await createTestTempDirectory("rea-raw-target-"); const path = join(directory, "fixture.js"); diff --git a/tests/boundary/mcp/rawBinaryMcp.test.ts b/tests/boundary/mcp/rawBinaryMcp.test.ts index c1d2029c2..26a0e6501 100644 --- a/tests/boundary/mcp/rawBinaryMcp.test.ts +++ b/tests/boundary/mcp/rawBinaryMcp.test.ts @@ -1,4 +1,4 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -10,7 +10,8 @@ import { createServer } from "../../../src/server/createServer.js"; import { toolContract } from "../../../src/contracts/toolContracts.js"; import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; -import { ok } from "../../../src/domain/result.js"; +import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js"; +import { err, ok } from "../../../src/domain/result.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { createCacheProvider, @@ -18,6 +19,7 @@ import { } from "../../fixtures/binarySession.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { silentLogger } from "../../../src/logger.js"; +import { GHIDRA_PROVIDER_IDENTITY } from "../../../src/ghidra/GhidraProviderCapabilities.js"; import type { CallToolResult } from "@modelcontextprotocol/server"; const connect = async ( @@ -50,29 +52,97 @@ const structured = (result: CallToolResult): Record => { return z.record(z.string(), z.unknown()).parse(result.structuredContent); }; -it("opens a headerless image when architecture and entry are supplied", async () => { - const root = await createTestTempDirectory("rea-raw-mcp-"); - const path = join(root, "ecu.bin"); - await writeFile(path, Buffer.from("1eff2fe1", "hex")); - const { client } = await connect(); - const opened = await client.callTool({ - name: "open_binary", - arguments: { +const withGhidraFixtureIdentity = ( + provider: AnalysisProvider, +): AnalysisProvider => { + const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" }; + const profile = createAnalysisProfile(identity, { fixture: true }); + return { + ...provider, + identity: () => identity, + capabilities: () => + provider.capabilities().map((capability) => ({ + ...capability, + provider: identity, + })), + resolveAnalysisProfile: () => Promise.resolve(ok({ profile })), + createClient: (target, _profile, context) => { + const client = provider.createClient(target, profile, context); + return { + ...client, + execute: async (operation, parameters, options) => { + const result = await client.execute(operation, parameters, options); + return result.ok + ? ok({ + ...result.value, + provider: identity, + analysisProfile: profile, + }) + : result; + }, + }; + }, + }; +}; + +it("keeps 6502 out of detected lifecycle output contracts", () => { + const schema = toolContract("open_binary").outputSchema; + const result = { + path: "/fixture/image.bin", + kind: "executable", + sha256: "a".repeat(64), + architecture: "6502", + }; + expect( + schema.safeParse({ result: { ...result, format: "pe" } }).success, + ).toBe(false); + expect( + schema.safeParse({ + result: { ...result, format: "raw-binary", entry_point: "0x8001" }, + }).success, + ).toBe(true); + const validateAdvertised = new Ajv2020({ strict: false }).compile( + z.toJSONSchema(schema), + ); + expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe( + false, + ); + expect( + validateAdvertised({ + result: { ...result, format: "raw-binary", entry_point: "0x8001" }, + }), + ).toBe(true); +}); + +it.each([ + ["arm", "0x08000000", "0x8000000", "1eff2fe1"], + ["6502", "0x008001", "0x8001", "a92a60"], +])( + "opens a headerless %s image when architecture and entry are supplied", + async (architecture, entryPoint, canonical, bytes) => { + const root = await createTestTempDirectory("rea-raw-mcp-"); + const path = join(root, "ecu.bin"); + await writeFile(path, Buffer.from(bytes, "hex")); + const { client } = await connect(); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path, + format: "raw-binary", + architecture, + entry_point: entryPoint, + }, + }); + expect(opened.isError).not.toBe(true); + expect(structured(opened).result).toMatchObject({ path, format: "raw-binary", - architecture: "arm", - entry_point: "0x08000000", - }, - }); - expect(opened.isError).not.toBe(true); - expect(structured(opened).result).toMatchObject({ - path, - format: "raw-binary", - kind: "executable", - architecture: "arm", - entry_point: "0x8000000", - }); -}); + kind: "executable", + architecture, + entry_point: canonical, + }); + }, +); it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => { const root = await createTestTempDirectory("rea-raw-mcp-alignment-"); @@ -217,3 +287,118 @@ it("reuses a canonical raw entry and replaces the session when the entry changes expect(created).toEqual(["0x8000000", "0x9000000"]); expect(closed).toEqual(["0x8000000"]); }); + +it.skipIf(process.platform !== "win32")( + "does not replay a saved read after switching to a Windows junction", + async () => { + const root = await createTestTempDirectory("rea-raw-mcp-linked-reopen-"); + const actual = join(root, "actual"); + const linked = join(root, "linked"); + await mkdir(actual); + const path = join(actual, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + await symlink(actual, linked, "junction"); + const created: string[] = []; + const executed: string[] = []; + const fixture = createCacheProvider([]); + const { client } = await connect( + withGhidraFixtureIdentity({ + ...fixture, + createClient: (target) => { + created.push(target.sourcePath ?? target.path); + return { + execute: (operation) => { + if (operation === "address_name") + executed.push(target.sourcePath ?? target.path); + if ( + operation === "address_name" && + target.sourcePath === join(linked, "image.bin") + ) + return Promise.resolve( + err(new ProviderAdapterError("fixture", operation)), + ); + return Promise.resolve( + observed(operation === "address_name" ? "entry" : null), + ); + }, + close: () => Promise.resolve(ok(null)), + }; + }, + }), + ); + const linkedPath = join(linked, "image.bin"); + for (const requested of [path, linkedPath]) { + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path: requested, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const address = await client.callTool({ + name: "address_name", + arguments: { address: "0x8001", document: "image.bin" }, + }); + expect(address.isError === true, JSON.stringify(address)).toBe( + requested === linkedPath, + ); + } + expect(created).toEqual([path, linkedPath]); + expect(executed).toEqual([path, linkedPath]); + }, +); + +it.skipIf(process.platform !== "win32")( + "reuses saved reads after a fixture admits a noncanonical Windows path", + async () => { + const root = await createTestTempDirectory("rea-raw-mcp-admitted-path-"); + const actual = join(root, "actual"); + const linked = join(root, "linked"); + await mkdir(actual); + const path = join(actual, "image.bin"); + await writeFile(path, Buffer.from("a92a60", "hex")); + await symlink(actual, linked, "junction"); + const calls: string[] = []; + const fixture = createCacheProvider([]); + // This fixture accepts the alias to isolate cache behavior after admission. + // The real Ghidra verifier checks that a junction itself is rejected. + const { client, session } = await connect( + withGhidraFixtureIdentity({ + ...fixture, + createClient: () => ({ + execute: (operation) => { + if (operation === "address_name") calls.push(operation); + return Promise.resolve( + observed(operation === "health" ? null : "entry"), + ); + }, + close: () => Promise.resolve(ok(null)), + }), + }), + ); + const linkedPath = join(linked, "image.bin"); + const opened = await client.callTool({ + name: "open_binary", + arguments: { + path: linkedPath, + format: "raw-binary", + architecture: "6502", + entry_point: "0x8001", + }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + expect(session.activeTarget()?.sourcePath).toBe(linkedPath); + expect(session.activeTarget()?.path).toBe(path); + for (const _ of [0, 1]) { + const address = await client.callTool({ + name: "address_name", + arguments: { address: "0x8001", document: "image.bin" }, + }); + expect(address.isError, JSON.stringify(address)).not.toBe(true); + } + expect(calls).toEqual(["address_name"]); + }, +); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 0bcf12bb2..53ffcd7d3 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -135,7 +135,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr const source = join(root, "bundle"); await mkdir(source); const archive = join(source, "app.asar"); - await createPackageWithOptions(contents, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(contents, archive, { unpack: "*.node" }); await rm(join(`${archive}.unpacked`, "native", "addon.node")); const nestedOutput = join(root, "nested-output"); diff --git a/tests/conformance/ghidra/6502.cfg b/tests/conformance/ghidra/6502.cfg new file mode 100644 index 000000000..cb85a4e76 --- /dev/null +++ b/tests/conformance/ghidra/6502.cfg @@ -0,0 +1,9 @@ +FEATURES { + STARTADDRESS: default = $8001; +} +MEMORY { + IMAGE: start = %S, size = $10000 - %S, file = %O; +} +SEGMENTS { + CODE: load = IMAGE, type = ro; +} diff --git a/tests/conformance/ghidra/6502.s b/tests/conformance/ghidra/6502.s new file mode 100644 index 000000000..201f8bcd2 --- /dev/null +++ b/tests/conformance/ghidra/6502.s @@ -0,0 +1,19 @@ +; Source-owned NMOS 6502 fixture: direct call, conditional branch, and arithmetic. +.setcpu "6502" +.segment "CODE" +.export entry, add_three + +entry: + lda #7 + jsr add_three + cmp #10 + bne mismatch + lda #42 + rts +mismatch: + lda #0 + rts +add_three: + clc + adc #3 + rts diff --git a/tests/conformance/readmeConsistency.test.ts b/tests/conformance/readmeConsistency.test.ts index 79a1fc518..90f8e5886 100644 --- a/tests/conformance/readmeConsistency.test.ts +++ b/tests/conformance/readmeConsistency.test.ts @@ -35,6 +35,20 @@ const shellExamples = (content: string): string[] => ); describe("onboarding documentation product facts", () => { + it.each([ + ["docs/native-investigation.md", "raw-binary.md#nmos-6502"], + ["docs/ghidra-riscv.md", "raw-binary.md"], + ])( + "routes raw admission guidance in %s to the owning guide", + async (path, target) => { + const content = await readFile(resolve(path), "utf8"); + const links = [...content.matchAll(/\]\(([^)\s]+)\)/gu)].map( + (match) => match[1], + ); + expect(links, path).toContain(target); + }, + ); + it("keeps the installation MCP example aligned with the package registration command", async () => { const content = await readFile(resolve("docs/installation.md"), "utf8"); expect(jsonExamples(content)).toContainEqual( diff --git a/tests/fixtures/strippedAsarAddon.ts b/tests/fixtures/strippedAsarAddon.ts index 5ff76356c..3422ac2ca 100644 --- a/tests/fixtures/strippedAsarAddon.ts +++ b/tests/fixtures/strippedAsarAddon.ts @@ -29,7 +29,7 @@ export const createStrippedAsarAddon = async ( await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); } - await createPackageWithOptions(source, archive, { unpack: "**/*.node" }); + await createPackageWithOptions(source, archive, { unpack: "*.node" }); await writeFile( join(`${archive}.unpacked`, "node_modules", "btime", "binding.node"), stripped,