diff --git a/docs/.vitepress/documentation.ts b/docs/.vitepress/documentation.ts index 02358ac553..d79ffaaeca 100644 --- a/docs/.vitepress/documentation.ts +++ b/docs/.vitepress/documentation.ts @@ -88,6 +88,11 @@ export const documentationSections = [ source: "guides/javascript/electron-observation.md", route: "/electron-observation", }, + { + text: "V8 code cache inspection", + source: "guides/javascript/v8-code-cache.md", + route: "/v8-code-cache", + }, ], }, { diff --git a/docs/README.md b/docs/README.md index 93aacf26a1..4be7663644 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ product pages. The VitePress documentation source is this `docs/` tree. - [Passive Node and Electron runtime observation](guides/javascript/javascript-runtime-observation.md) - [JavaScript static/runtime reconciliation](guides/javascript/javascript-runtime-reconciliation.md) - [Electron file-page observation](guides/javascript/electron-observation.md) +- [V8 code cache inspection](guides/javascript/v8-code-cache.md) ## Browser and captured websites diff --git a/docs/guides/javascript/v8-code-cache.md b/docs/guides/javascript/v8-code-cache.md new file mode 100644 index 0000000000..65dcde0d2f --- /dev/null +++ b/docs/guides/javascript/v8-code-cache.md @@ -0,0 +1,121 @@ +# V8 code cache inspection + +`inspect_v8_code_cache` reads one explicitly selected V8 code cache container as static +bytes: a bytenode `.jsc`, the bytes `vm.Script.createCachedData()` writes, or a cache +carried inside a Node single-executable application or an Electron app. It never executes +the file, never starts a V8 runtime, never installs bytenode, and never invokes a +decompiler. + +```bash +rea inspect-v8-code-cache /absolute/path/to/module.jsc --json +rea inspect-v8-code-cache /absolute/path/to/module.jsc --minStringBytes 8 --json +``` + +The MCP tool is `inspect_v8_code_cache` with the same inputs and the same retained +Evidence. + +## What is read + +A V8 code cache is a producer-written container, not a documented file format. This reader +admits one container shape and reports everything else as an observation, so a build it +has never seen still yields facts instead of a false parse. + +The 32-byte header is eight little-endian 32-bit words. Every word is reported in +`header.fields` with its byte offset, its observed value, the role this reader believes it +has, and how confident that role is: + +| Offset | Role | Status | +| -------------- | -------------- | ----------- | +| `0x00` | magic | confirmed | +| `0x04` | version hash | inferred | +| `0x08` | source length | confirmed | +| `0x0c` | flag hash | inferred | +| `0x10` | build constant | inferred | +| `0x14` | payload length | confirmed | +| `0x18`, `0x1c` | reserved | unconfirmed | + +`confirmed` means the role is demonstrated by the reader's own tests against the producer. +`inferred` means the word's behaviour is consistent with the role but a different producer +could plausibly differ. `unconfirmed` means the word is reported and nothing more is +claimed. + +Only three facts gate admission: + +1. the header size this reader pins (32 bytes); +2. the V8 code cache magic high half, `0xc0de`. The low half is + `ExternalReferenceTable::kSize` and varies per V8 build, so it is reported as a build + fingerprint and never required to match a fixed value; +3. the declared payload length must equal the bytes that follow the header exactly. + +`header.source_length` is the producer's recorded source length in UTF-16 code units. +V8's serializer calls this field the source hash; on the producers measured here its value +equals the source length exactly, including sources whose UTF-8 byte length differs, so the +reader reports it under the name the measurement supports. Treat that name as an +observation, not as a claim about what any other build stores there. +`header.producer` names the producer family from the flag bits in that word. Bit 29 +marks `vm.compileFunction` (`compile_function`); bit 30 marks `vm.SourceTextModule` +(`module`). When neither flag is present, the producer remains `unknown`: +`vm.Script`, bytenode output and older unflagged function caches cannot be +separated by those bits alone. The source-length mask is `0x1fffffff`, so both +flags sit outside the reported length. This describes the admitted header +profile rather than a version-independent V8 ABI. + +## Failed inspection and provenance + +After a stable read completes, format and observation-budget refusals retain a +`file-snapshot-hash` partial observation with the selected path, exact SHA-256 and +byte count. This proves which bytes were read; it does not claim the format was +successfully decoded. A refusal before read completion does not invent an identity. +Descriptor cleanup remains owned for retry if finalization fails. + +## Embedded byte patterns + +`embedded_strings` lists printable runs found in the payload, each with its absolute byte +offset in the selected file and its observed byte length. `min_string_bytes` (default 4) +sets the shortest reported run; `max_strings` caps how many may be reported, and exceeding +it fails without partial success. + +These are **byte-pattern observations inside serialized V8 heap data, not parsed heap +objects**. A reported run is not proof V8 retained it as a string. A literal that is absent +from the payload is not proof it is absent from the program: V8 may encode a string in a +form the run scan does not surface, and a string can be produced at runtime rather than +retained. + +A run longer than 4096 bytes is reported with `truncated: true` and a `retained_bytes` +prefix. + +## Limitations + +Every result states these, and they are part of the contract: + +- the payload is serialized V8 heap data, not source; no logic is recovered and no + bytecode is decompiled; +- recovering logic needs a version-pinned V8 decompiler (View8-style) that this tool + neither provides nor depends on; +- header roles beyond magic, source length, and payload length are version-dependent + observations; +- the tool never executes the selected file and never claims recovered source. + +## Failure behaviour + +An unknown magic is a typed `unsupported_target` failure, because the file is not a V8 +code cache. A short header, a payload-length disagreement, or bytes past the declared +payload is a typed `invalid_request` failure. Neither is a partial parse, and no Evidence +is recorded for them. + +`max_file_bytes` (default 32 MiB, ceiling 64 MiB) is the caller-selected read budget. The +payload SHA-256 is computed over the declared payload, so two containers with the same +payload length and content but different version hashes have different whole-file digests +and identical payload digests. + +## Relationship to other tools + +- `inspect_binary_layout` reads the surrounding ELF/PE container of a single-executable + application; it does not decode the embedded code cache. +- A cache embedded in a Node single-executable application or an Electron app must be + **extracted first**: this reader admits a container whose magic is at byte zero, so + passing the surrounding ELF/PE executable here returns `unsupported_target`. Run + `extract_artifact` on the application, then pass the extracted `.jsc` path to this tool. +- `inspect_artifact` inventories the file without decoding V8 structures. +- This tool is in the same family as `inspect_python_pyc`, `inspect_erlang_beam`, and + `inspect_kernel_module`: one explicit local file, bounded parsing, no execution. diff --git a/docs/specs/v8/code-cache-inspection/plan.md b/docs/specs/v8/code-cache-inspection/plan.md new file mode 100644 index 0000000000..d780e5ad01 --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/plan.md @@ -0,0 +1,40 @@ +# Plan + +## Shape + +The container format is the only thing this feature knows, so the parser owns it and +everything else consumes its facts. + +- `src/domain/v8/v8CodeCache.ts` holds the schema, the bit constants and the bounds, so a + reader and a test share one definition of what a container is. +- `src/v8/V8CodeCacheParser.ts` decodes a `Buffer` into retained strings and producer + facts. It is given bytes only; it never opens a file. +- `src/v8/V8CodeCacheProvider.ts` owns the observation budget, the file read and the + remediation text. It is the boundary that turns bytes into a reported fact. +- `src/cli/v8CodeCacheCommands.ts` and the MCP registration are thin adapters over the + provider, so both surfaces report the same facts. + +## Measured premises + +Each of these was measured on Node 24.22.1 before the parser was written, because the +container is undocumented and the layout had to be derived rather than assumed: + +- `vm.Script.createCachedData()` writes `0x…|length` for its producer word. +- `vm.compileFunction` sets `0x20000000`, and `vm.SourceTextModule` sets `0x40000000`. +- The payload retains UTF-8 strings above the threshold, and the retained set grows with + the source's literals rather than with the bytecode size. + +## Order + +1. Pin the format in the domain schema so later steps cannot disagree about it. +2. Write the parser against the measured producer words. +3. Add the provider, the budget and its remediation. +4. Expose the CLI command, then the MCP tool, over the same provider. +5. Document the command and the report, and link the guide from both indexes. + +## Budget + +The retained-string scan is the only unbounded part of the read, so it is bounded on three +axes at once: a maximum string count, a minimum string length, and a maximum artifact size. +The provider reports which of the three was reached and whether raising or lowering it is +the caller's next step, so a refusal is actionable rather than a bare truncation. diff --git a/docs/specs/v8/code-cache-inspection/spec.md b/docs/specs/v8/code-cache-inspection/spec.md new file mode 100644 index 0000000000..bca7465571 --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/spec.md @@ -0,0 +1,58 @@ +# Inspect a V8 code cache container + +## Outcome + +A bytenode `.jsc`, the bytes `vm.Script.createCachedData()` writes, and a code cache +carried inside a Node single-executable application or an Electron application are all +opaque today: the analysis reports the containing file and nothing about the cache. An +analyst has to install bytenode, guess a V8 version, and run the file to learn anything, +which executes attacker-controlled code and changes the state under study. + +`rea inspect-v8-code-cache [--minStringBytes ]` reads one explicitly selected +container as static bytes and reports its producer, the strings it retains above a +threshold, and the recoverable source metadata, without executing the file, starting a +V8 runtime, installing bytenode, or invoking a decompiler. The same facts are available +through the `inspect_v8_code_cache` MCP tool. + +## Requirements + +- The reader accepts a file whose first words describe a V8 code cache header; anything + else is refused before the payload is walked, so an unrelated file cannot be read as a + cache by accident. +- The producer is decided from the header's producer bits, not from the file extension: + a module bit means `vm.SourceTextModule`, a compile-function bit means + `vm.compileFunction`, and neither bit means `unknown`. The unflagged case is not + `vm.Script` by inference — whether V8 marks a function cache is a build detail. A + `vm.compileFunction` cache reads `0x20000004` for a 4-unit source on Node 24.22.1 and + `0x00000004` on Node 22.23.4, so an unflagged word could be either producer. The + observed word stays in `header.fields` for a caller that trusts a specific build + fingerprint. +- Retained strings are reported above the caller's byte threshold, with + `--minStringBytes` bounded so a pathological threshold cannot make the read unbounded. +- Observation is bounded by a budget that reports which bound it hit and how to raise it, + rather than silently truncating. +- The container's file location is reported as given, and the payload is never written + back or repaired. + +## Non-goals + +- Decoding the payload back into source. The container's bytecode is version-specific and + the tool does not attempt to reconstruct source from it. +- Executing the container to obtain runtime facts. +- Recognising an encrypted or compressed wrapper around a cache. + +## Verification + +- The `vm.Script` producer word and its retained strings come from output this suite + generates through `vm.Script.createCachedData()`, and the `vm.compileFunction` case + compiles through `vm.compileFunction` for the same reason. +- The `vm.SourceTextModule` producer bit is checked by setting the measured bit on a real + `vm.Script` cache rather than by compiling through `vm.SourceTextModule`, so that case + covers the bit's meaning and not a cache from that producer. +- The CLI, MCP, absolute-path and decimal-argument boundary contracts have their own + tests. +- The budget tests call `observationBudgetRecovery` and assert its structured facts — the + boundary reached and which knob may move. `observationBudgetRemediation` renders that + struct into a sentence, so it is held to the struct rather than to its wording: the + sentence must not advise a knob the struct closed, and must mention a smaller artifact + exactly when the struct requires one. Rewording stays free; wrong advice does not. diff --git a/docs/specs/v8/code-cache-inspection/tasks.md b/docs/specs/v8/code-cache-inspection/tasks.md new file mode 100644 index 0000000000..dd509ccb6f --- /dev/null +++ b/docs/specs/v8/code-cache-inspection/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +- [x] Record the outcome, requirements and non-goals. +- [x] Measure the producer words and the retained-string layout on Node 24.22.1. +- [x] Add the domain schema, the bit constants and the observation bounds. +- [x] Add the parser, reading the payload by index into the integer table. +- [x] Add the provider with the budget and its remediation. +- [x] Add the CLI command and register the MCP tool over the same provider. +- [x] Document the command, the report and the bounds. +- [x] Link the guide from `docs/README.md` and the VitePress index. +- [x] Add parser, CLI, MCP, absolute-path and decimal-argument tests. +- [x] Hold the budget advice to the recovery struct rather than to its wording. +- [x] Run the static, formatting, duplication, documentation and test gates. +- [x] Address the independent review. diff --git a/scripts/lib/test-projects.mjs b/scripts/lib/test-projects.mjs index af4c70630e..55ff667bec 100644 --- a/scripts/lib/test-projects.mjs +++ b/scripts/lib/test-projects.mjs @@ -19,7 +19,7 @@ export const SOURCE_TEST_PROJECTS = [ group: "source", include: [ "src/*.test.ts", - "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,desktop,dotnet,electron,erlang,evm,firmware,flutter,frida,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,providers,python,reference,rust,server,sqlite,wasm,windows}/**/*.test.ts", + "src/{android,apkSignature,apktool,jeb,adb,artifacts,browser,config,cutter,desktop,dotnet,electron,erlang,evm,firmware,flutter,frida,pcap,ghidra,go,gpu,hopper,ida,inspector,javascript,main,native,process,providers,python,reference,rust,server,sqlite,v8,wasm,windows}/**/*.test.ts", ], }, { diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a32b375d29..c36a664662 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -543,6 +543,7 @@ const workflowAvailabilityFor = ( name === "inspect_python_pyc" || name === "inspect_apple_ips" || name === "inspect_erlang_beam" || + name === "inspect_v8_code_cache" || name === "inspect_electron_fuses" ) return { reason: "available", remediation: null }; diff --git a/src/application/v8CodeCache/V8CodeCachePort.ts b/src/application/v8CodeCache/V8CodeCachePort.ts new file mode 100644 index 0000000000..a00aaf1604 --- /dev/null +++ b/src/application/v8CodeCache/V8CodeCachePort.ts @@ -0,0 +1,20 @@ +import type { + ExecutionOptions, + ProviderIdentity, +} from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { Result } from "../../domain/result.js"; +import type { + InspectV8CodeCacheInput, + V8CodeCache, +} from "../../domain/v8/v8CodeCache.js"; + +/** Stable read and V8 code cache container decoding, independent of a V8 runtime. */ +export interface V8CodeCachePort { + readonly identity: ProviderIdentity; + close(): Promise; + inspect( + input: InspectV8CodeCacheInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/v8CodeCache/V8CodeCacheService.ts b/src/application/v8CodeCache/V8CodeCacheService.ts new file mode 100644 index 0000000000..f927acf23f --- /dev/null +++ b/src/application/v8CodeCache/V8CodeCacheService.ts @@ -0,0 +1,101 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { V8CodeCachePort } from "./V8CodeCachePort.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; +import { createEvidence, type Evidence } from "../../domain/evidence.js"; +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, + type V8CodeCache, +} from "../../domain/v8/v8CodeCache.js"; +import { err, type Result } from "../../domain/result.js"; +import { completeProviderEvidence } from "../ProviderOperationExecution.js"; +import { parseAnalysisInput } from "../AnalysisInput.js"; + +const OPERATION = "inspect_v8_code_cache"; + +/** Shared caller validation, output parsing and Evidence provenance for CLI/MCP. */ +export class V8CodeCacheService { + constructor(readonly provider: V8CodeCachePort) {} + + close(): Promise { + return this.provider.close(); + } + + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + const input = parseAnalysisInput( + OPERATION, + rawInput, + inspectV8CodeCacheInputSchema, + options?.signal, + ); + if (!input.ok) return input; + const result = await this.provider.inspect(input.value, options); + if (!result.ok) return result; + const report = v8CodeCacheSchema.safeParse(result.value); + if ( + !report.success || + report.data.artifact.path !== input.value.path || + !validReport(report.data, input.value) + ) + return err( + new AnalysisOutputError( + OPERATION, + "V8 code cache provider returned malformed output or changed artifact identity.", + ), + ); + const value = report.data; + return completeProviderEvidence( + createEvidence( + { + path: value.artifact.path, + format: "file", + sha256: value.artifact.sha256, + }, + this.provider.identity, + { + operation: OPERATION, + parameters: input.value, + result: value, + confidence: "observed", + limitations: value.limitations, + locations: [ + { kind: "artifact-path" as const, path: value.artifact.path }, + { + kind: "file-offset-range" as const, + start: value.payload.offset, + end: value.payload.offset + value.payload.bytes, + }, + ], + }, + ), + options?.signal, + ); + } +} + +const validReport = ( + report: V8CodeCache, + input: { + max_file_bytes: number; + max_strings: number; + min_string_bytes: number; + }, +): boolean => { + if (report.artifact.bytes > input.max_file_bytes) return false; + if (report.embedded_strings.length > input.max_strings) return false; + if ( + report.payload.offset + report.payload.bytes !== report.artifact.bytes || + report.payload.offset !== report.header.header_bytes + ) + return false; + return report.embedded_strings.every( + (observation) => + observation.bytes >= input.min_string_bytes && + observation.offset >= report.payload.offset && + observation.offset + observation.bytes <= report.artifact.bytes, + ); +}; diff --git a/src/artifacts/StableArtifactFailure.ts b/src/artifacts/StableArtifactFailure.ts index 8265aeb15d..17bd02e64f 100644 --- a/src/artifacts/StableArtifactFailure.ts +++ b/src/artifacts/StableArtifactFailure.ts @@ -1,3 +1,4 @@ +import type { StableArtifactSnapshot } from "./readStableArtifact.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; @@ -52,8 +53,9 @@ export const projectStableArtifactFailure = ( path: string, providerId: string, operation: string, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { - const observation = completedStableArtifactObservation(cause, path); + const observation = completedStableArtifactObservation(cause, path, snapshot); if (observation !== undefined) primary.retainPartialObservation(observation); const cleanup = artifactCleanupFailure(cause, providerId, operation); if (cleanup === undefined) return primary; diff --git a/src/artifacts/StableArtifactObservation.ts b/src/artifacts/StableArtifactObservation.ts index f177d947f0..8185e9e437 100644 --- a/src/artifacts/StableArtifactObservation.ts +++ b/src/artifacts/StableArtifactObservation.ts @@ -2,14 +2,18 @@ import { fileSnapshotPartialObservationSchema, type FileSnapshotPartialObservation, } from "../domain/fileSnapshotObservation.js"; -import { completedStableArtifact } from "./readStableArtifact.js"; +import { + type StableArtifactSnapshot, + completedStableArtifact, +} from "./readStableArtifact.js"; /** Completed file identity survives finalization; it does not establish decoded content. */ export const completedStableArtifactObservation = ( cause: unknown, path: string, + completedSnapshot?: StableArtifactSnapshot, ): FileSnapshotPartialObservation | undefined => { - const snapshot = completedStableArtifact(cause); + const snapshot = completedSnapshot ?? completedStableArtifact(cause); return snapshot === undefined ? undefined : fileSnapshotPartialObservationSchema.parse({ diff --git a/src/cli.ts b/src/cli.ts index 20ad639049..2b04626d16 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,6 +19,7 @@ import { registerIl2cppCommands } from "./cli/il2cppCommands.js"; import { registerRustCommands } from "./cli/rustCommands.js"; import { registerPythonPycCommands } from "./cli/pythonPycCommands.js"; import { registerErlangCommands } from "./cli/erlangCommands.js"; +import { registerV8CodeCacheCommands } from "./cli/v8CodeCacheCommands.js"; import { registerSqliteCommands } from "./cli/sqliteCommands.js"; import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js"; import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; @@ -211,6 +212,7 @@ export const createCli = ( registerRustCommands(cli, logger); registerPythonPycCommands(cli, logger); registerErlangCommands(cli, logger); + registerV8CodeCacheCommands(cli, logger); registerSqliteCommands(cli, logger, environment); registerArtifactCommands(cli, logger, analysis.runProviderAnalysis); }, diff --git a/src/cli/v8CodeCacheCommands.ts b/src/cli/v8CodeCacheCommands.ts new file mode 100644 index 0000000000..5c76991309 --- /dev/null +++ b/src/cli/v8CodeCacheCommands.ts @@ -0,0 +1,66 @@ +import { z } from "incur"; +import type { Logger } from "pino"; +import { localPathStringSchema } from "../domain/localPath.js"; +import { absoluteCallerPath } from "../filesystem/callerPath.js"; +import { createV8CodeCacheService } from "../composition/v8CodeCache.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; +import { cliDecimalField } from "./options.js"; +import { inspectV8CodeCacheInputSchema } from "../domain/v8/v8CodeCache.js"; +import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js"; +import { finishCliEvidenceCommand } from "./cliEvidenceCallerPath.js"; + +/** Inspect operator-selected V8 code caches through the same shared workflow as MCP. */ +export const registerV8CodeCacheCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.inspectV8CodeCache, { + description: + "Inspect a V8 code cache header and its embedded printable byte patterns without executing it", + args: z.object({ + path: localPathStringSchema.describe("Local V8 code cache (.jsc) file"), + }), + options: z.object({ + // Decimal tokens only: numeric coercion would read "0x10" as 16 and "1e3" as + // 1000. The shared fields keep their bounds, defaults and help. + maxFileBytes: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.max_file_bytes, + ), + maxStrings: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.max_strings, + ), + minStringBytes: cliDecimalField( + inspectV8CodeCacheInputSchema.shape.min_string_bytes, + ), + }), + run: ({ args, options }) => + withCommandCancellation(async (signal) => { + const v8CodeCache = createV8CodeCacheService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectV8CodeCache, + async () => + finishCliEvidenceCommand( + args.path, + () => + v8CodeCache.inspect( + { + path: absoluteCallerPath(args.path), + max_file_bytes: options.maxFileBytes, + max_strings: options.maxStrings, + min_string_bytes: options.minStringBytes, + }, + { signal }, + ), + v8CodeCache, + "rea-v8-code-cache", + "inspect_v8_code_cache", + signal, + ).then(projectCliAnalysisResult), + ); + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index f80a9f97af..29e947b959 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -75,6 +75,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectRustBinary: "inspect-rust-binary", inspectPythonPyc: "inspect-python-pyc", inspectErlangBeam: "inspect-erlang-beam", + inspectV8CodeCache: "inspect-v8-code-cache", inspectPeResources: "inspect-pe-resources", inspectPeCertificates: "inspect-pe-certificates", inspectPeImports: "inspect-pe-imports", diff --git a/src/composition/capabilityDeclarations.ts b/src/composition/capabilityDeclarations.ts index e801eb65c1..80ce1da280 100644 --- a/src/composition/capabilityDeclarations.ts +++ b/src/composition/capabilityDeclarations.ts @@ -51,6 +51,7 @@ import { } from "../domain/webTraceProviders.js"; import { ELECTRON_FUSE_PROVIDER_IDENTITY } from "../electron/ElectronFuseProviderIdentity.js"; import { ERLANG_BEAM_PROVIDER_IDENTITY } from "../erlang/ErlangBeamProviderIdentity.js"; +import { V8_CODE_CACHE_PROVIDER_IDENTITY } from "../v8/V8CodeCacheProviderIdentity.js"; import { EVMOLE_PROVIDER_IDENTITY } from "../evm/EvmoleProviderIdentity.js"; import { BINWALK_PROVIDER_IDENTITY, @@ -448,6 +449,13 @@ export const CAPABILITY_DECLARATIONS = { }), ], }, + v8CodeCache: { + providers: [ + providerBinding(V8_CODE_CACHE_PROVIDER_IDENTITY, { + group: "V8_CODE_CACHE_TOOL_CONTRACTS", + }), + ], + }, recordedCrash: { providers: [ providerBinding(PWNTOOLS_PROVIDER_IDENTITY, ["inspect_recorded_crash"]), diff --git a/src/composition/capabilityServices.ts b/src/composition/capabilityServices.ts index 8e8215c22f..8a3368367f 100644 --- a/src/composition/capabilityServices.ts +++ b/src/composition/capabilityServices.ts @@ -30,6 +30,7 @@ import { DesktopBuildComputers } from "../application/desktop/DesktopBuildComput import { createGoBinaryService } from "./go.js"; import { createRustBinaryService } from "./rust.js"; import { createBeamService } from "./erlang.js"; +import { createV8CodeCacheService } from "./v8CodeCache.js"; import { createModelArtifactService } from "./model.js"; import { createIl2cppMetadataService } from "./il2cpp.js"; import { createElectronFuseService } from "./electronFuses.js"; @@ -95,6 +96,7 @@ import type { ElectronFuseService } from "../application/electron/ElectronFuseSe import type { RustBinaryService } from "../application/rust/RustBinaryService.js"; import type { PythonPycService } from "../application/python/PythonPycService.js"; import type { BeamService } from "../application/erlang/BeamService.js"; +import type { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import type { BrowserObservationPort } from "../application/BrowserObservationPort.js"; @@ -130,6 +132,7 @@ export interface CapabilityServiceOptions { readonly rustBinary?: RustBinaryService; readonly pythonPyc?: PythonPycService; readonly erlangBeam?: BeamService; + readonly v8CodeCache?: V8CodeCacheService; readonly electronFuses?: ElectronFuseService; readonly recordedCrash?: RecordedCrashService; readonly firmwareAnalysis?: FirmwareAnalysisPort; @@ -337,6 +340,9 @@ const modules = { erlangBeam: service( ({ options }) => options.erlangBeam ?? createBeamService(), ), + v8CodeCache: service( + ({ options }) => options.v8CodeCache ?? createV8CodeCacheService(), + ), modelArtifact: service(() => createModelArtifactService()), gpuCodeObjects: service(() => createGpuCodeObjectsService()), il2cppMetadata: service( diff --git a/src/composition/v8CodeCache.ts b/src/composition/v8CodeCache.ts new file mode 100644 index 0000000000..b4bcffa622 --- /dev/null +++ b/src/composition/v8CodeCache.ts @@ -0,0 +1,6 @@ +import { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; +import { V8CodeCacheProvider } from "../v8/V8CodeCacheProvider.js"; + +/** Compose bundled V8 code cache inspection without a V8 runtime or any child process. */ +export const createV8CodeCacheService = (): V8CodeCacheService => + new V8CodeCacheService(new V8CodeCacheProvider()); diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 00e213b942..3a7b6608da 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -13,6 +13,7 @@ import { IL2CPP_TOOL_CONTRACTS } from "./il2cpp/il2cppToolContracts.js"; import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.js"; import { PYTHON_PYC_TOOL_CONTRACTS } from "./python/pythonPycToolContracts.js"; import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "./v8/v8CodeCacheToolContracts.js"; import { SQLITE_TOOL_CONTRACTS } from "./sqlite/sqliteToolContracts.js"; import { ANALYSIS_VIEW_TOOL_CONTRACTS } from "./analysisViewToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; @@ -63,6 +64,7 @@ export const TOOL_CONTRACTS = [ ...RUST_TOOL_CONTRACTS, ...PYTHON_PYC_TOOL_CONTRACTS, ...ERLANG_BEAM_TOOL_CONTRACTS, + ...V8_CODE_CACHE_TOOL_CONTRACTS, ...WASM_TOOL_CONTRACTS, ...APK_SIGNATURE_TOOL_CONTRACTS, ...SQLITE_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 5f7f0ffc17..55cebccab2 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -295,6 +295,7 @@ const TOOL_EFFECT_AUDIT: Readonly> = { inspect_apple_ips: evidence, inspect_devicetree: evidence, inspect_kernel_module: evidence, + inspect_v8_code_cache: evidence, inspect_pe_load_config: evidence, inspect_binary_hardening: evidence, inspect_analysis_view: evidence, diff --git a/src/contracts/toolFamilies.ts b/src/contracts/toolFamilies.ts index 034d0919c6..234bbb41dc 100644 --- a/src/contracts/toolFamilies.ts +++ b/src/contracts/toolFamilies.ts @@ -18,6 +18,7 @@ import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; import { APK_SIGNATURE_TOOL_CONTRACTS } from "./apkSignature/apkSignatureToolContracts.js"; import { ERLANG_BEAM_TOOL_CONTRACTS } from "./erlang/beamToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "./v8/v8CodeCacheToolContracts.js"; import { PYTHON_PYC_TOOL_CONTRACTS } from "./python/pythonPycToolContracts.js"; import { DESKTOP_APPLICATION_TOOL_CONTRACTS } from "./desktopApplicationToolContracts.js"; import { DESKTOP_BUILD_TOOL_CONTRACTS } from "./desktopBuildToolContracts.js"; @@ -95,6 +96,7 @@ export const TOOL_CONTRACT_GROUPS = { SESSION_TOOL_CONTRACTS, SIGNAL_TOOL_CONTRACTS, SQLITE_TOOL_CONTRACTS, + V8_CODE_CACHE_TOOL_CONTRACTS, WASM_TOOL_CONTRACTS, WEB_NETWORK_CAPTURE_TOOL_CONTRACTS, WEB_RUNTIME_TOOL_CONTRACTS, @@ -152,6 +154,7 @@ export const TOOL_FAMILY_DEFINITIONS = [ ...TOOL_CONTRACT_GROUPS.RUST_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.PYTHON_PYC_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.ERLANG_BEAM_TOOL_CONTRACTS, + ...TOOL_CONTRACT_GROUPS.V8_CODE_CACHE_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.WASM_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.APK_SIGNATURE_TOOL_CONTRACTS, ...TOOL_CONTRACT_GROUPS.SQLITE_TOOL_CONTRACTS, diff --git a/src/contracts/v8/v8CodeCacheToolContracts.ts b/src/contracts/v8/v8CodeCacheToolContracts.ts new file mode 100644 index 0000000000..c4fec1d2a9 --- /dev/null +++ b/src/contracts/v8/v8CodeCacheToolContracts.ts @@ -0,0 +1,34 @@ +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, +} from "../../domain/v8/v8CodeCache.js"; +import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; + +/** Static V8 code cache inspection is independent of active native-analysis providers. */ +export const V8_CODE_CACHE_TOOL_CONTRACTS = [ + { + name: "inspect_v8_code_cache", + ...toolContractMetadata("inspect_v8_code_cache"), + kind: "artifact-provider", + description: + "Inspect one explicit local V8 code cache container (a bytenode .jsc, the bytes vm.Script.createCachedData writes, or a cache embedded in a Node single-executable application or Electron app) as static bytes, with no child process, no bytenode dependency, and no decompiler. Returns the artifact path/SHA-256/byte length, the eight observed header words each with its role and whether that role is confirmed, inferred, or unconfirmed, the observed magic and the build-stable high half, the producer family, the recorded source length, the payload offset/length/SHA-256, and printable byte sequences inside the payload with absolute byte ranges. Admission is narrow: a header this reader pins, the V8 code cache magic high half, and a payload length that reaches exactly the end of the file; an unknown magic, a short header, or a payload-length disagreement returns a typed failure and never a partial parse. Header field roles other than magic, source length, and payload length are inferred from one producer and are version-dependent, so they are reported as observations rather than a fixed ABI. Embedded strings are byte-pattern observations inside serialized V8 heap data, not parsed heap objects: a reported sequence is not proof V8 retained it as a string and a missing literal is not proof it is absent. The payload is not source; recovering logic needs a version-pinned View8-style decompiler that this tool neither provides nor depends on. Reads a stable regular file; exhausted observation budgets return no partial success. Portable on supported Node hosts; no V8 runtime is required.", + inputSchema: inspectV8CodeCacheInputSchema, + outputSchema: evidenceResultOf(v8CodeCacheSchema), + examples: [ + { + title: "Identify the V8 producer of a bytenode code cache", + input: { path: "/artifacts/module.jsc" }, + }, + { + title: "Locate retained config keys in the payload", + input: { + path: "/artifacts/module.jsc", + min_string_bytes: 8, + max_strings: 256, + }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts index 85b548d9b2..a53d7a9536 100644 --- a/src/domain/artifactOperationError.ts +++ b/src/domain/artifactOperationError.ts @@ -38,6 +38,7 @@ export class ArtifactOperationError extends AnalysisError { | "inspect_playwright_trace" | "inspect_kernel_module" | "inspect_erlang_beam" + | "inspect_v8_code_cache" | "extract_artifact" | "decode_interface_builder" | "inspect_asset_catalog" diff --git a/src/domain/fileSnapshotObservation.ts b/src/domain/fileSnapshotObservation.ts index 3df6fac533..a57c537147 100644 --- a/src/domain/fileSnapshotObservation.ts +++ b/src/domain/fileSnapshotObservation.ts @@ -3,7 +3,7 @@ import { z } from "zod"; const byteCount = z.number().int().nonnegative().safe(); const sha256 = z.string().regex(/^[a-f0-9]{64}$/u); -/** Completed immutable-file facts retained when read finalization fails. */ +/** Completed immutable-file facts retained when finalization or later interpretation fails. */ // Null identity fields mean caller-declared exclusion, never an absent file or // sensitivity inferred from its name. Unmarked identities remain exact. export const fileSnapshotPartialObservationSchema = z.discriminatedUnion( diff --git a/src/domain/v8/v8CodeCache.ts b/src/domain/v8/v8CodeCache.ts new file mode 100644 index 0000000000..a6f86cf3b4 --- /dev/null +++ b/src/domain/v8/v8CodeCache.ts @@ -0,0 +1,227 @@ +import { z } from "zod"; + +import { digestSchema } from "../digests.js"; +import { absoluteLocalPathStringSchema } from "../localPath.js"; + +const uint = z.number().int().min(0).max(0xffffffff); +const offset = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); + +/** + * Hard ceiling on the caller-selected input read budget. The payload scan is a + * single synchronous pass, so this bounds how long one request can hold the + * process; it matches the pinned CPython pyc reader's 64 MiB ceiling. + */ +export const MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES = 67108864; +/** Hard ceiling on the V8 code cache container header this reader accepts. */ +export const V8_CODE_CACHE_HEADER_BYTES = 32; +/** + * V8 stamps every serialized code cache with `0xC0DE0000 ^ ExternalReferenceTable::kSize`. + * Only the high half is stable across builds, so the low half is a build fingerprint and + * is never used as an admission condition. + */ +export const V8_CODE_CACHE_MAGIC_HIGH = 0xc0de; +/** Hard ceiling on reported embedded byte-pattern observations. */ +export const MAX_INSPECT_V8_CODE_CACHE_STRINGS = 65536; +/** Shortest printable run reported as a byte-pattern observation. */ +export const MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES = 4; + +/** Upper bound the input schema admits for `min_string_bytes`. */ +export const MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES = 256; +/** Longest single reported run; longer runs are truncated and said to be truncated. */ +export const MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES = 4096; +/** `vm.compileFunction` sets this bit in the source-length word. */ +export const V8_CODE_CACHE_COMPILE_FUNCTION_BIT = 0x20000000; + +/** + * Bit 30 of the source-length word marks `vm.SourceTextModule` output. + * + * Measured on Node 24.22.1: a module cache reads `0x40000013` for a 19-unit source and a + * script cache reads `0x00000013` for the same length. The source-length mask is + * `0x1fffffff`, so this bit and the function bit are both outside it. + */ +export const V8_CODE_CACHE_MODULE_BIT = 0x40000000; +/** Source length occupies the low bits of that word. */ +export const V8_CODE_CACHE_SOURCE_LENGTH_MASK = 0x1fffffff; + +/** Bounded inspection of one V8 code cache container. Never executes the payload. */ +export const inspectV8CodeCacheInputSchema = z.strictObject({ + path: absoluteLocalPathStringSchema.describe( + "Absolute filesystem path to the selected V8 code cache (.jsc) file", + ), + max_file_bytes: z + .number() + .int() + .min(36) + .max(MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES) + .default(32 * 1024 * 1024) + .describe( + "Maximum stable artifact bytes to read; exceeding the budget fails without partial success", + ), + max_strings: z + .number() + .int() + .min(1) + .max(MAX_INSPECT_V8_CODE_CACHE_STRINGS) + .default(4096) + .describe( + "Maximum embedded byte-pattern observations to report; exceeding the budget fails without partial success", + ), + min_string_bytes: z + .number() + .int() + .min(MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES) + .max(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES) + .default(MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES) + .describe("Shortest printable run reported as one observation"), +}); + +export type InspectV8CodeCacheInput = z.infer< + typeof inspectV8CodeCacheInputSchema +>; + +/** Every header role is reported as observed, with how confident that role is. */ +export const V8_CODE_CACHE_FIELD_STATUSES = [ + "confirmed", + "inferred", + "unconfirmed", +] as const; + +/** + * One 32-bit header word. `role` names what the reader believes the word is; `status` + * says whether that role is demonstrated, inferred from behaviour, or merely observed. + * The container is version-dependent, so this table is data, not a fixed ABI. + */ +const headerFieldSchema = z.strictObject({ + /** Byte offset of this word inside the container header. */ + offset: uint, + /** The observed little-endian value. */ + value: uint, + role: z.string().min(1), + status: z.enum(V8_CODE_CACHE_FIELD_STATUSES), +}); + +const headerSchema = z.strictObject({ + /** The full observed magic word. Its low half is a V8 build fingerprint. */ + magic: uint, + /** High half of the magic, the only part stable across V8 builds. */ + magic_build_stable: uint, + /** Every header word, in container order. */ + fields: z.array(headerFieldSchema).length(8), + /** Bytes this reader treats as header before the payload begins. */ + header_bytes: uint, + /** Source length in UTF-16 code units, as recorded by the producer, if determinable. */ + source_length: uint.nullable(), + /** + * Producer family observed from the source-length word. + * + * V8 sets bit 29 for `vm.compileFunction` and bit 30 for `vm.SourceTextModule`, the two + * upper bits the source-length mask leaves out. Measured on Node 24.22.1: a module cache + * reads `0x40000013` for a 19-unit source, so a producer check that only looks at the + * function bit reports a module cache as a plain script. + * + * `unknown` is the unflagged case, and it is not `vm.Script` for certain: whether V8 + * marks a function cache at all is a build detail. Measured on Node 24.22.1 a + * `vm.compileFunction` cache reads `0x20000004` for a 4-unit source while Node 22.23.4 + * writes `0x00000004` for the same call. An unflagged word is therefore either + * `vm.Script` or a `vm.compileFunction` cache from a build that does not mark it, and + * the file carries nothing that separates the two. + */ + producer: z.enum(["script", "compile_function", "module", "unknown"]), +}); + +/** One printable run found in the payload. An observation, not a decoded heap object. */ +const embeddedStringSchema = z.strictObject({ + /** Absolute byte offset in the selected file. */ + offset, + bytes: uint, + /** How many bytes were retained; shorter than `bytes` when the run was truncated. */ + retained_bytes: uint, + truncated: z.boolean(), + text: z.string(), +}); + +export const v8CodeCacheSchema = z + .strictObject({ + artifact: z.strictObject({ + path: absoluteLocalPathStringSchema, + sha256: digestSchema, + bytes: offset, + }), + header: headerSchema, + payload: z.strictObject({ + offset, + bytes: offset, + sha256: digestSchema, + /** Header bytes after the container header that are not part of the declared payload. */ + trailing_bytes: offset, + }), + /** + * Byte-pattern observations inside the payload. Presence of a literal here is not + * proof V8 retained it as a heap string, and absence is not proof it did not. + */ + embedded_strings: z.array(embeddedStringSchema), + coverage: z.strictObject({ + status: z.literal("complete"), + /** Payload bytes examined for printable runs. */ + scanned_bytes: offset, + strings: uint, + }), + limitations: z.array(z.string().min(1)).min(1), + }) + .superRefine((report, context) => { + if (report.header.header_bytes !== V8_CODE_CACHE_HEADER_BYTES) + context.addIssue({ + code: "custom", + path: ["header", "header_bytes"], + message: "This reader only accepts its pinned container header size.", + }); + if (report.header.magic_build_stable !== V8_CODE_CACHE_MAGIC_HIGH) + context.addIssue({ + code: "custom", + path: ["header", "magic_build_stable"], + message: + "The admitted magic must carry the stable V8 code cache high half.", + }); + if (report.payload.offset + report.payload.bytes !== report.artifact.bytes) + context.addIssue({ + code: "custom", + path: ["payload", "bytes"], + message: + "The declared payload length must reach the end of the selected file.", + }); + if (report.coverage.strings !== report.embedded_strings.length) + context.addIssue({ + code: "custom", + path: ["coverage", "strings"], + message: + "Observation coverage disagrees with the listed embedded strings.", + }); + report.embedded_strings.forEach((observation, index) => { + if (observation.retained_bytes > observation.bytes) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "retained_bytes"], + message: "A retained prefix cannot exceed the observed run length.", + }); + if ( + observation.truncated !== + observation.retained_bytes < observation.bytes + ) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "truncated"], + message: "Truncation must agree with the retained byte count.", + }); + if ( + observation.offset < report.payload.offset || + observation.offset + observation.bytes > report.artifact.bytes + ) + context.addIssue({ + code: "custom", + path: ["embedded_strings", index, "offset"], + message: "An observation must lie inside the declared payload.", + }); + }); + }); + +export type V8CodeCache = z.infer; diff --git a/src/domain/v8/v8CodeCacheBudget.ts b/src/domain/v8/v8CodeCacheBudget.ts new file mode 100644 index 0000000000..3a3823633a --- /dev/null +++ b/src/domain/v8/v8CodeCacheBudget.ts @@ -0,0 +1,5 @@ +/** Caller-selected budgets the parser enforces while reading one container. */ +export interface V8CodeCacheInputBudget { + readonly max_strings: number; + readonly min_string_bytes: number; +} diff --git a/src/erlang/BeamProvider.ts b/src/erlang/BeamProvider.ts index a68d45b004..76419eec11 100644 --- a/src/erlang/BeamProvider.ts +++ b/src/erlang/BeamProvider.ts @@ -5,7 +5,10 @@ import { projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -52,9 +55,10 @@ export class BeamProvider implements BeamPort { } async inspect(input: InspectErlangBeamInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -74,7 +78,7 @@ export class BeamProvider implements BeamPort { ); }); } catch (cause) { - return err(beamFailure(cause, input)); + return err(beamFailure(cause, input, snapshot)); } } } @@ -193,7 +197,11 @@ const beamFailurePrimary = ( ); }; -const beamFailure = (cause: unknown, input: InspectErlangBeamInput) => { +const beamFailure = ( + cause: unknown, + input: InspectErlangBeamInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = beamFailurePrimary(artifactFilePrimaryFacts(cause), input); return projectStableArtifactFailure( cause, @@ -201,5 +209,6 @@ const beamFailure = (cause: unknown, input: InspectErlangBeamInput) => { input.path, ERLANG_BEAM_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; diff --git a/src/firmware/devicetree/DevicetreeProvider.ts b/src/firmware/devicetree/DevicetreeProvider.ts index 26ba90e132..1756ff4cb7 100644 --- a/src/firmware/devicetree/DevicetreeProvider.ts +++ b/src/firmware/devicetree/DevicetreeProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -52,9 +55,10 @@ export class DevicetreeProvider implements DevicetreePort { } async inspect(input: InspectDevicetreeInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -74,7 +78,7 @@ export class DevicetreeProvider implements DevicetreePort { ); }); } catch (cause) { - return err(devicetreeFailure(cause, input)); + return err(devicetreeFailure(cause, input, snapshot)); } } } @@ -191,7 +195,11 @@ const devicetreeFailurePrimary = ( ); }; -const devicetreeFailure = (cause: unknown, input: InspectDevicetreeInput) => { +const devicetreeFailure = ( + cause: unknown, + input: InspectDevicetreeInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = devicetreeFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -202,5 +210,6 @@ const devicetreeFailure = (cause: unknown, input: InspectDevicetreeInput) => { input.path, DEVICETREE_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 930d6d6f36..9404a04a21 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -5,7 +5,10 @@ import { stableArtifactInputRecovery, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -144,7 +147,11 @@ const failureForPrimary = ( }); }; -const failureFor = (cause: unknown, path: string): AnalysisError => { +const failureFor = ( + cause: unknown, + path: string, + snapshot?: StableArtifactSnapshot, +): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); return projectStableArtifactFailure( cause, @@ -152,6 +159,7 @@ const failureFor = (cause: unknown, path: string): AnalysisError => { path, GO_BINARY_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; @@ -276,9 +284,10 @@ export class GoBinaryProvider implements GoBinaryPort { input: Omit, options?: ExecutionOptions, ): Promise> { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, INPUT_BYTES, options?.signal, @@ -342,7 +351,7 @@ export class GoBinaryProvider implements GoBinaryPort { return ok(parsed.data); }); } catch (cause: unknown) { - return err(failureFor(cause, input.path)); + return err(failureFor(cause, input.path, snapshot)); } } } diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index 913186c749..e8c7574f4d 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -65,9 +68,10 @@ export class AppleIpsProvider implements AppleIpsPort { } async inspect(input: InspectAppleIpsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -88,7 +92,7 @@ export class AppleIpsProvider implements AppleIpsPort { return ok(report); }); } catch (cause) { - return err(appleIpsFailure(cause, input)); + return err(appleIpsFailure(cause, input, snapshot)); } } } @@ -269,6 +273,7 @@ const appleIpsFailurePrimary = ( const appleIpsFailure = ( cause: unknown, input: InspectAppleIpsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = appleIpsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -280,5 +285,6 @@ const appleIpsFailure = ( input.path, APPLE_IPS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/elf/ElfNotesProvider.ts b/src/native/elf/ElfNotesProvider.ts index e96de9954f..1aeaeed590 100644 --- a/src/native/elf/ElfNotesProvider.ts +++ b/src/native/elf/ElfNotesProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -57,9 +60,10 @@ export class ElfNotesProvider implements ElfNotesPort { } async inspect(input: InspectElfNotesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -79,7 +83,7 @@ export class ElfNotesProvider implements ElfNotesPort { ); }); } catch (cause) { - return err(elfNotesFailure(cause, input)); + return err(elfNotesFailure(cause, input, snapshot)); } } } @@ -233,7 +237,11 @@ const elfNotesFailurePrimary = ( ); }; -const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { +const elfNotesFailure = ( + cause: unknown, + input: InspectElfNotesInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = elfNotesFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -244,5 +252,6 @@ const elfNotesFailure = (cause: unknown, input: InspectElfNotesInput) => { input.path, ELF_NOTES_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/elf/KernelModuleProvider.ts b/src/native/elf/KernelModuleProvider.ts index 1c83e695cb..89a1ce53f7 100644 --- a/src/native/elf/KernelModuleProvider.ts +++ b/src/native/elf/KernelModuleProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -57,9 +60,10 @@ export class KernelModuleProvider implements KernelModulePort { } async inspect(input: InspectKernelModuleInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -79,7 +83,7 @@ export class KernelModuleProvider implements KernelModulePort { ); }); } catch (cause) { - return err(kernelModuleFailure(cause, input)); + return err(kernelModuleFailure(cause, input, snapshot)); } } } @@ -228,6 +232,7 @@ const kernelModuleFailurePrimary = ( const kernelModuleFailure = ( cause: unknown, input: InspectKernelModuleInput, + snapshot?: StableArtifactSnapshot, ) => { const primary = kernelModuleFailurePrimary( artifactFilePrimaryFacts(cause), @@ -239,5 +244,6 @@ const kernelModuleFailure = ( input.path, KERNEL_MODULE_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/exports/NativeExportsProvider.ts b/src/native/exports/NativeExportsProvider.ts index d564ed8826..9e47ec9f21 100644 --- a/src/native/exports/NativeExportsProvider.ts +++ b/src/native/exports/NativeExportsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -58,9 +61,10 @@ export class NativeExportsProvider implements NativeExportsPort { } async inspect(input: InspectNativeExportsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -77,7 +81,7 @@ export class NativeExportsProvider implements NativeExportsPort { return ok(report); }); } catch (cause) { - return err(nativeExportsFailure(cause, input)); + return err(nativeExportsFailure(cause, input, snapshot)); } } } @@ -193,6 +197,7 @@ const nativeExportsFailurePrimary = ( const nativeExportsFailure = ( cause: unknown, input: InspectNativeExportsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = nativeExportsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -204,5 +209,6 @@ const nativeExportsFailure = ( input.path, NATIVE_EXPORTS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/hardening/BinaryHardeningProvider.ts b/src/native/hardening/BinaryHardeningProvider.ts index 707af54a82..63acc5a161 100644 --- a/src/native/hardening/BinaryHardeningProvider.ts +++ b/src/native/hardening/BinaryHardeningProvider.ts @@ -4,7 +4,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -64,9 +67,10 @@ export class BinaryHardeningProvider implements BinaryHardeningPort { input: InspectBinaryHardeningInput, options?: ExecutionOptions, ) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -87,7 +91,7 @@ export class BinaryHardeningProvider implements BinaryHardeningPort { return ok(report); }); } catch (cause) { - return err(binaryHardeningFailure(cause, input)); + return err(binaryHardeningFailure(cause, input, snapshot)); } } } @@ -234,6 +238,7 @@ const binaryHardeningFailurePrimary = ( const binaryHardeningFailure = ( cause: unknown, input: InspectBinaryHardeningInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => projectStableArtifactFailure( cause, @@ -241,4 +246,5 @@ const binaryHardeningFailure = ( input.path, BINARY_HARDENING_PROVIDER.id, OPERATION, + snapshot, ); diff --git a/src/native/pe/PeCertificatesProvider.ts b/src/native/pe/PeCertificatesProvider.ts index 0e016d4e87..580c753dbb 100644 --- a/src/native/pe/PeCertificatesProvider.ts +++ b/src/native/pe/PeCertificatesProvider.ts @@ -6,7 +6,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -58,9 +61,10 @@ export class PeCertificatesProvider implements PeCertificatesPort { } async inspect(input: InspectPeCertificatesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -80,7 +84,7 @@ export class PeCertificatesProvider implements PeCertificatesPort { ); }); } catch (cause) { - return err(peCertificatesFailure(cause, input)); + return err(peCertificatesFailure(cause, input, snapshot)); } } } @@ -229,6 +233,7 @@ const peCertificatesFailurePrimary = ( const peCertificatesFailure = ( cause: unknown, input: InspectPeCertificatesInput, + snapshot?: StableArtifactSnapshot, ) => { const primary = peCertificatesFailurePrimary( artifactFilePrimaryFacts(cause), @@ -240,5 +245,6 @@ const peCertificatesFailure = ( input.path, PE_CERTIFICATES_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeImportsProvider.ts b/src/native/pe/PeImportsProvider.ts index 7324ff68e6..4fb628a28a 100644 --- a/src/native/pe/PeImportsProvider.ts +++ b/src/native/pe/PeImportsProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -66,9 +69,10 @@ export class PeImportsProvider implements PeImportsPort { } async inspect(input: InspectPeImportsInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -90,7 +94,7 @@ export class PeImportsProvider implements PeImportsPort { return ok(report); }); } catch (cause) { - return err(peImportsFailure(cause, input)); + return err(peImportsFailure(cause, input, snapshot)); } } } @@ -236,6 +240,7 @@ const peImportsFailurePrimary = ( const peImportsFailure = ( cause: unknown, input: InspectPeImportsInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = peImportsFailurePrimary( artifactFilePrimaryFacts(cause), @@ -247,5 +252,6 @@ const peImportsFailure = ( input.path, PE_IMPORTS_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeLoadConfigProvider.ts b/src/native/pe/PeLoadConfigProvider.ts index 9131c763d0..00b5841ba9 100644 --- a/src/native/pe/PeLoadConfigProvider.ts +++ b/src/native/pe/PeLoadConfigProvider.ts @@ -5,7 +5,10 @@ import { } from "../../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -64,9 +67,10 @@ export class PeLoadConfigProvider implements PeLoadConfigPort { } async inspect(input: InspectPeLoadConfigInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -88,7 +92,7 @@ export class PeLoadConfigProvider implements PeLoadConfigPort { return ok(report); }); } catch (cause) { - return err(peLoadConfigFailure(cause, input)); + return err(peLoadConfigFailure(cause, input, snapshot)); } } } @@ -213,6 +217,7 @@ const peLoadConfigFailurePrimary = ( const peLoadConfigFailure = ( cause: unknown, input: InspectPeLoadConfigInput, + snapshot?: StableArtifactSnapshot, ): AnalysisError => { const primary = peLoadConfigFailurePrimary( artifactFilePrimaryFacts(cause), @@ -224,5 +229,6 @@ const peLoadConfigFailure = ( input.path, PE_LOAD_CONFIG_PROVIDER.id, OPERATION, + snapshot, ); }; diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index e44c3a3cc8..b8504ccfae 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -7,7 +7,10 @@ import { import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js"; import { fixedNativeAllowance } from "../../domain/native/nativeDeadlines.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -55,9 +58,10 @@ export class PeResourcesProvider implements PeResourcesPort { } async inspect(input: InspectPeResourcesInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, input.max_file_bytes, options?.signal, @@ -78,7 +82,7 @@ export class PeResourcesProvider implements PeResourcesPort { ); }); } catch (cause) { - return err(peResourcesFailure(cause, input)); + return err(peResourcesFailure(cause, input, snapshot)); } } } @@ -215,7 +219,11 @@ const peResourcesFailurePrimary = ( ); }; -const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { +const peResourcesFailure = ( + cause: unknown, + input: InspectPeResourcesInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = peResourcesFailurePrimary( artifactFilePrimaryFacts(cause), input, @@ -226,5 +234,6 @@ const peResourcesFailure = (cause: unknown, input: InspectPeResourcesInput) => { input.path, PE_RESOURCES_PROVIDER.id, "inspect_pe_resources", + snapshot, ); }; diff --git a/src/python/PythonPycProvider.ts b/src/python/PythonPycProvider.ts index 488e44f625..706176c8e2 100644 --- a/src/python/PythonPycProvider.ts +++ b/src/python/PythonPycProvider.ts @@ -11,7 +11,10 @@ import { type ArtifactFilePrimaryFacts, } from "../artifacts/ArtifactFileOperation.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import type { PythonPycPort } from "../application/python/PythonPycPort.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; @@ -162,7 +165,11 @@ const failureForPrimary = ( }); }; -const failureFor = (cause: unknown, input: InspectPythonPycInput) => { +const failureFor = ( + cause: unknown, + input: InspectPythonPycInput, + snapshot?: StableArtifactSnapshot, +) => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), input); return projectStableArtifactFailure( cause, @@ -170,6 +177,7 @@ const failureFor = (cause: unknown, input: InspectPythonPycInput) => { input.path, PYTHON_PYC_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; @@ -191,8 +199,9 @@ export class PythonPycProvider implements PythonPycPort { } async inspect(input: InspectPythonPycInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; try { - const snapshot = await this.#resources.run(() => + snapshot = await this.#resources.run(() => readStableArtifact( input.path, input.max_file_bytes, @@ -213,7 +222,7 @@ export class PythonPycProvider implements PythonPycPort { ), ); } catch (cause) { - return err(failureFor(cause, input)); + return err(failureFor(cause, input, snapshot)); } } } diff --git a/src/rust/RustBinaryProvider.ts b/src/rust/RustBinaryProvider.ts index 69d48477e3..c993d5b003 100644 --- a/src/rust/RustBinaryProvider.ts +++ b/src/rust/RustBinaryProvider.ts @@ -5,7 +5,10 @@ import { projectArtifactCloseFailure, } from "../artifacts/StableArtifactFailure.js"; import { isSelectedRegularFileError } from "../filesystem/RegularFile.js"; -import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; import { artifactFilePrimaryFacts, type ArtifactFilePrimaryFacts, @@ -71,9 +74,10 @@ export class RustBinaryProvider implements RustBinaryPort { input: InspectRustBinaryInput, options?: ExecutionOptions, ): Promise> { + let snapshot: StableArtifactSnapshot | undefined; try { return await this.#resources.run(async () => { - const snapshot = await readStableArtifact( + snapshot = await readStableArtifact( input.path, RUST_BINARY_INPUT_BYTES, options?.signal, @@ -123,7 +127,7 @@ export class RustBinaryProvider implements RustBinaryPort { return ok(parsed.data); }); } catch (cause: unknown) { - return err(failureFor(cause, input.path)); + return err(failureFor(cause, input.path, snapshot)); } } } @@ -276,7 +280,11 @@ const failureForPrimary = ( return artifactFailure(facts, path); }; -const failureFor = (cause: unknown, path: string): AnalysisError => { +const failureFor = ( + cause: unknown, + path: string, + snapshot?: StableArtifactSnapshot, +): AnalysisError => { const primary = failureForPrimary(artifactFilePrimaryFacts(cause), path); return projectStableArtifactFailure( cause, @@ -284,6 +292,7 @@ const failureFor = (cause: unknown, path: string): AnalysisError => { path, RUST_BINARY_PROVIDER_IDENTITY.id, OPERATION, + snapshot, ); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 89aef18a95..b2da447671 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -38,6 +38,7 @@ import { registerIl2cppTools } from "./registerIl2cppTools.js"; import { registerRustTools } from "./registerRustTools.js"; import { registerPythonPycTool } from "./registerPythonPycTool.js"; import { registerErlangTools } from "./registerErlangTools.js"; +import { registerV8CodeCacheTool } from "./registerV8CodeCacheTool.js"; import { registerAnalysisViewTool } from "./registerAnalysisViewTool.js"; import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; import { registerFridaTools } from "./registerFridaTools.js"; @@ -209,6 +210,7 @@ export const createServer = ( goBinary, rustBinary, erlangBeam, + v8CodeCache, modelArtifact, gpuCodeObjects, il2cppMetadata, @@ -619,6 +621,13 @@ export const createServer = ( analysisAdmission, evidenceById, ); + registerV8CodeCacheTool( + server, + v8CodeCache, + toolLogger, + recordEvidence, + analysisAdmission, + ); registerWasmTools( server, wasmArtifact, diff --git a/src/server/registerV8CodeCacheTool.ts b/src/server/registerV8CodeCacheTool.ts new file mode 100644 index 0000000000..893f8c5c46 --- /dev/null +++ b/src/server/registerV8CodeCacheTool.ts @@ -0,0 +1,31 @@ +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import type { V8CodeCacheService } from "../application/v8CodeCache/V8CodeCacheService.js"; +import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import type { Logger } from "pino"; +import { createEvidenceToolHandler } from "./contractToolHandler.js"; +import type { WithAdmittedAnalysis } from "./analysisAdmission.js"; +import { toolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register the explicit-file V8 code cache workflow and retain its complete Evidence. */ +export const registerV8CodeCacheTool = ( + server: EvidenceMcpServer, + service: V8CodeCacheService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], + withAdmittedAnalysis?: WithAdmittedAnalysis, +): void => { + const contract = toolContract("inspect_v8_code_cache"); + const handler = createEvidenceToolHandler( + server, + (input, options) => service.inspect(input, options), + logger, + recordEvidence, + withAdmittedAnalysis, + ); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + handler(contract), + ); +}; diff --git a/src/v8/V8CodeCache.errors.ts b/src/v8/V8CodeCache.errors.ts new file mode 100644 index 0000000000..8ecd42fefc --- /dev/null +++ b/src/v8/V8CodeCache.errors.ts @@ -0,0 +1,24 @@ +import type { V8CodeCacheInputBudget } from "../domain/v8/v8CodeCacheBudget.js"; + +/** Distinguish corrupt containers from unrecognized formats. */ +export class V8CodeCacheFormatFailure extends Error { + constructor( + readonly kind: "malformed" | "unsupported", + message: string, + ) { + super(message); + this.name = "V8CodeCacheFormatFailure"; + } +} + +/** A caller-selected budget was exceeded while scanning the payload. */ +export class V8CodeCacheBudgetFailure extends Error { + constructor( + readonly boundary: keyof V8CodeCacheInputBudget, + readonly limit: number, + message: string, + ) { + super(message); + this.name = "V8CodeCacheBudgetFailure"; + } +} diff --git a/src/v8/V8CodeCache.fixture.ts b/src/v8/V8CodeCache.fixture.ts new file mode 100644 index 0000000000..be81df68ca --- /dev/null +++ b/src/v8/V8CodeCache.fixture.ts @@ -0,0 +1,26 @@ +import vm from "node:vm"; + +/** + * Generate a real V8 code cache instead of committing a version-pinned binary. Node + * embeds V8, so the fixture producer is the same code path that writes a bytenode `.jsc`. + */ +export const v8CodeCacheFixture = ( + source = "function add(a, b) { return a + b; } add(1, 2);", +): Buffer => + new vm.Script(source, { produceCachedData: true }).createCachedData(); + +/** `vm.compileFunction` sets the producer bit in the source-length word. */ +export const v8FunctionCodeCacheFixture = (source = "return 1 + 2;"): Buffer => + vm.compileFunction(source, [], { produceCachedData: true }).cachedData ?? + Buffer.alloc(0); + +/** Rewrite one header word of a real fixture to exercise a specific header value. */ +export const withHeaderWord = ( + cache: Buffer, + index: number, + value: number, +): Buffer => { + const copy = Buffer.from(cache); + copy.writeUInt32LE(value >>> 0, index * 4); + return copy; +}; diff --git a/src/v8/V8CodeCacheParser.test.ts b/src/v8/V8CodeCacheParser.test.ts new file mode 100644 index 0000000000..f1ec862b06 --- /dev/null +++ b/src/v8/V8CodeCacheParser.test.ts @@ -0,0 +1,257 @@ +import { createHash } from "node:crypto"; +import { expect, it } from "vitest"; +import { + inspectV8CodeCacheInputSchema, + v8CodeCacheSchema, + V8_CODE_CACHE_HEADER_BYTES, + type V8CodeCache, +} from "../domain/v8/v8CodeCache.js"; +import { parseV8CodeCache } from "./V8CodeCacheParser.js"; +import { + v8CodeCacheFixture, + v8FunctionCodeCacheFixture, + withHeaderWord, +} from "./V8CodeCache.fixture.js"; + +const defaults = inspectV8CodeCacheInputSchema.parse({ + path: "/artifacts/module.jsc", +}); + +const identity = (bytes: Buffer) => ({ + path: "/artifacts/module.jsc", + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, +}); + +const parse = (bytes: Buffer): V8CodeCache => { + const report = parseV8CodeCache(bytes, identity(bytes), defaults); + const validated = v8CodeCacheSchema.safeParse(report); + if (!validated.success) throw new Error(validated.error.message); + return report; +}; + +const real = v8CodeCacheFixture(); + +it("reports the observed header of a real V8 code cache", () => { + const report = parse(real); + expect(report.header.header_bytes).toBe(32); + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.magic).toBe(real.readUInt32LE(0)); + // The payload-length word reaches exactly the end of the container. + expect(report.payload.offset).toBe(32); + expect(report.payload.bytes).toBe(real.length - 32); + expect(report.payload.bytes).toBe(real.readUInt32LE(20)); + expect(report.payload.offset + report.payload.bytes).toBe( + report.artifact.bytes, + ); + expect(report.coverage.status).toBe("complete"); +}); + +it("names every header word with its role and confidence", () => { + const report = parse(real); + expect(report.header.fields).toHaveLength(8); + expect(report.header.fields.map((field) => field.offset)).toEqual([ + 0, 4, 8, 12, 16, 20, 24, 28, + ]); + expect(report.header.fields.map((field) => field.role)).toEqual([ + "magic", + "version_hash", + "source_length", + "flag_hash", + "build_constant", + "payload_length", + "reserved", + "reserved", + ]); + // Every reported value is the byte-level truth, independent of the inferred role. + report.header.fields.forEach((field) => { + expect(field.value).toBe(real.readUInt32LE(field.offset)); + }); + // Confidence is stated per field rather than implied. + expect( + report.header.fields.filter((field) => field.status === "unconfirmed"), + ).toHaveLength(2); + expect( + report.header.fields + .filter((field) => field.status === "confirmed") + .map((f) => f.role), + ).toEqual(["magic", "source_length", "payload_length"]); +}); + +it("reports the source length the producer recorded", () => { + const source = "function longer(a, b) { return a * b + 1; } longer(3, 4);"; + const cache = v8CodeCacheFixture(source); + const report = parse(cache); + expect(report.header.source_length).toBe(source.length); + // An unflagged word is `unknown`, not `script`: a `vm.compileFunction` cache from a + // build that does not mark bit 29 reads the same as a script, so the container alone + // cannot separate them. The observed word stays available in `header.fields`. + expect(report.header.producer).toBe("unknown"); + expect(report.header.fields[2]?.value).toBe(cache.readUInt32LE(8)); +}); + +it("reports a module cache as a module rather than a script", () => { + // Bit 30 of the source-length word marks `vm.SourceTextModule` output. Measured on + // Node 24.22.1: a real module cache reads `0x40000013` for a 19-unit source while a + // script of the same length reads `0x00000013`, so a producer check that only tests + // the function bit reports the module cache as a plain script. + const script = v8CodeCacheFixture(); + const word = script.readUInt32LE(8); + const length = word & 0x1fffffff; + expect(word & 0x40000000).toBe(0); + expect(word & 0x20000000).toBe(0); + expect(parse(script).header.producer).toBe("unknown"); + + const moduleCache = withHeaderWord(script, 2, word | 0x40000000); + const report = parse(moduleCache); + expect(report.header.producer).toBe("module"); + // The module bit sits outside the source-length mask, so the length is unchanged. + expect(report.header.source_length).toBe(length); +}); + +it("keeps the function and module flags distinct", () => { + const script = v8CodeCacheFixture("var y = 1;"); + const length = script.readUInt32LE(8) & 0x1fffffff; + expect( + parse(withHeaderWord(script, 2, 0x20000000 | length)).header.producer, + ).toBe("compile_function"); + expect( + parse(withHeaderWord(script, 2, 0x40000000 | length)).header.producer, + ).toBe("module"); + // Both bits set is not a shape V8 produces; the module flag wins so the reported + // family stays one of the three observed kinds. + expect( + parse(withHeaderWord(script, 2, 0x60000000 | length)).header.producer, + ).toBe("module"); +}); + +it("separates the compileFunction producer bit from the source length", () => { + const source = "return 7;"; + const cache = v8FunctionCodeCacheFixture(source); + const word = cache.readUInt32LE(8); + const report = parse(cache); + // Whether `vm.compileFunction` sets bit 29 in the source-length word is a V8 build + // detail, not a contract: Node 24.22.1 writes `0x20000009` for this source while Node + // 22.23.4 does not set the bit at all. Assert against the word the runtime produced + // rather than against one build's spelling, so the check is about the parser reading + // that word correctly; the flag meanings themselves are pinned by the synthetic + // `withHeaderWord` cases above. + expect(report.header.producer).toBe( + (word & 0x20000000) === 0 ? "unknown" : "compile_function", + ); + // The source length is the low bits either way, so it is the part that must hold. + expect(report.header.source_length).toBe(word & 0x1fffffff); + expect(report.header.source_length).toBe(source.length); +}); + +it("reports an embedded run at its absolute file offset", () => { + const marker = "UNIQUE_CONFIG_KEY_9f3a"; + // An object-literal value survives in the serialized payload as plain bytes. + const cache = v8CodeCacheFixture( + `var settings = { key: "${marker}" }; settings.key;`, + ); + const report = parse(cache); + const observation = report.embedded_strings.find((entry) => + entry.text.includes(marker), + ); + expect(observation).toBeDefined(); + // The reported range points at the marker inside the selected file. + expect(realSlice(cache, observation!.offset, observation!.bytes)).toContain( + marker, + ); + expect(observation!.offset).toBeGreaterThanOrEqual(32); + expect(report.coverage.strings).toBe(report.embedded_strings.length); +}); + +it("labels observations as byte patterns and never claims recovered source", () => { + const report = parse(real); + expect(report.limitations.join(" ")).toContain("byte-pattern observations"); + expect(report.limitations.join(" ")).toContain("not source"); + expect(report.limitations.join(" ")).toContain("View8"); + // No field can be mistaken for recovered source text. + expect(Object.keys(report)).not.toContain("source"); +}); + +it("fails closed on a foreign magic", () => { + const foreign = withHeaderWord(real, 0, 0xdeadbeef); + expect(() => parse(foreign)).toThrow( + expect.objectContaining({ + name: "V8CodeCacheFormatFailure", + kind: "unsupported", + message: expect.stringContaining("not a V8 code cache"), + }), + ); +}); + +it("fails closed on a payload-length mismatch", () => { + const corrupt = withHeaderWord(real, 5, real.readUInt32LE(20) + 8); + expect(() => parse(corrupt)).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringContaining("truncated or corrupt"), + }), + ); +}); + +it("fails closed on a truncated header", () => { + expect(() => parse(real.subarray(0, V8_CODE_CACHE_HEADER_BYTES - 1))).toThrow( + expect.objectContaining({ kind: "malformed" }), + ); +}); + +it("fails closed when fewer payload bytes follow the header than it declares", () => { + // Cut bytes off the end without rewriting the declared payload length. + expect(() => parse(real.subarray(0, real.length - 16))).toThrow( + expect.objectContaining({ + kind: "malformed", + message: expect.stringContaining("truncated or corrupt"), + }), + ); +}); + +it("treats the observed header as version-dependent rather than a fixed ABI", () => { + // A producer with a different build constant and version hash is still admitted: only + // the magic high half and the payload-length relation gate admission. + const otherBuild = withHeaderWord( + withHeaderWord(real, 4, 0x4be86e94), + 1, + 0x11111111, + ); + const report = parse(otherBuild); + expect(report.header.fields[4]?.value).toBe(0x4be86e94); + expect(report.payload.bytes).toBe(real.readUInt32LE(20)); +}); + +it("enforces the caller-selected observation budget", () => { + const crowded = v8CodeCacheFixture( + `var a = { a: "AAAAAAAAAAAA", b: "BBBBBBBBBBBB", c: "CCCCCCCCCCCC" }; a.a;`, + ); + expect(() => + parseV8CodeCache(crowded, identity(crowded), { + ...defaults, + max_strings: 1, + }), + ).toThrow( + expect.objectContaining({ + name: "V8CodeCacheBudgetFailure", + message: expect.stringContaining("max_strings"), + }), + ); +}); + +it("raises min_string_bytes and reports only runs that long", () => { + const bytes = v8CodeCacheFixture( + `var a = { k: "SHORT", long: "${"L".repeat(80)}" }; a.k;`, + ); + const few = parseV8CodeCache(bytes, identity(bytes), { + ...defaults, + min_string_bytes: 64, + }); + expect(few.embedded_strings.length).toBeGreaterThan(0); + few.embedded_strings.forEach((entry) => { + expect(entry.bytes).toBeGreaterThanOrEqual(64); + }); +}); + +const realSlice = (bytes: Buffer, offset: number, length: number): string => + bytes.subarray(offset, offset + length).toString("latin1"); diff --git a/src/v8/V8CodeCacheParser.ts b/src/v8/V8CodeCacheParser.ts new file mode 100644 index 0000000000..8a69024590 --- /dev/null +++ b/src/v8/V8CodeCacheParser.ts @@ -0,0 +1,187 @@ +import { createHash } from "node:crypto"; +import { + MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES, + V8_CODE_CACHE_COMPILE_FUNCTION_BIT, + V8_CODE_CACHE_MODULE_BIT, + V8_CODE_CACHE_HEADER_BYTES, + V8_CODE_CACHE_MAGIC_HIGH, + V8_CODE_CACHE_SOURCE_LENGTH_MASK, + type V8CodeCache, +} from "../domain/v8/v8CodeCache.js"; +import type { V8CodeCacheInputBudget } from "../domain/v8/v8CodeCacheBudget.js"; +import { + V8CodeCacheBudgetFailure, + V8CodeCacheFormatFailure, +} from "../v8/V8CodeCache.errors.js"; + +const unsupported = (message: string): never => { + throw new V8CodeCacheFormatFailure("unsupported", message); +}; +const malformed = (message: string): never => { + throw new V8CodeCacheFormatFailure("malformed", message); +}; + +/** + * Header word roles. Reproduced from observation against the producing V8, not from an + * assumed ABI: the container is version-dependent and only the magic high half and the + * payload-length relation are relied on as admission conditions. + */ +const HEADER_ROLES: ReadonlyArray<{ + role: string; + status: "confirmed" | "inferred" | "unconfirmed"; +}> = [ + { role: "magic", status: "confirmed" }, + { role: "version_hash", status: "inferred" }, + { role: "source_length", status: "confirmed" }, + { role: "flag_hash", status: "inferred" }, + { role: "build_constant", status: "inferred" }, + { role: "payload_length", status: "confirmed" }, + { role: "reserved", status: "unconfirmed" }, + { role: "reserved", status: "unconfirmed" }, +]; + +const LIMITATIONS = [ + "The payload is serialized V8 heap data, not source. No logic is recovered and no bytecode is decompiled.", + "Recovering logic requires a version-pinned V8 decompiler (View8-style) that this tool neither installs nor runs.", + "Embedded strings are byte-pattern observations inside the payload, not parsed heap objects: a reported sequence is not proof V8 retained it as a string, and a missing literal is not proof it is absent.", + "Header field roles beyond magic, source length, and payload length are inferred from one producer and are version-dependent; treat them as observations, not a fixed ABI.", + "An unflagged producer word is reported as unknown: whether V8 marks a vm.compileFunction cache at all is a build detail, so the container cannot separate a vm.Script from an unmarked function cache on its own.", +]; + +/** Printable ASCII and horizontal tab, the run alphabet of one observation. */ +const isPrintable = (byte: number): boolean => + (byte >= 0x20 && byte <= 0x7e) || byte === 0x09; + +const payloadSha256 = (bytes: Buffer): string => + createHash("sha256").update(bytes).digest("hex"); + +/** + * Read one V8 code cache container without executing it. + * + * Admission is deliberately narrow: the header size this reader pins, the V8 code cache + * magic high half, and payload length reaching exactly the end of the file. Everything + * else is reported as an observation so a producer this reader has never seen still + * yields its facts instead of a false parse. + */ +export const parseV8CodeCache = ( + bytes: Buffer, + artifact: { path: string; sha256: string; bytes: number }, + input: V8CodeCacheInputBudget, +): V8CodeCache => { + if (bytes.length !== artifact.bytes) + malformed("Selected artifact length disagrees with the retained bytes."); + if (bytes.length < V8_CODE_CACHE_HEADER_BYTES) + malformed( + `Selected file is shorter than the ${String(V8_CODE_CACHE_HEADER_BYTES)}-byte V8 code cache header.`, + ); + + const field = (index: number): number => bytes.readUInt32LE(index * 4); + const magic = field(0); + const magicHigh = magic >>> 16; + if (magicHigh !== V8_CODE_CACHE_MAGIC_HIGH) + unsupported( + `Container magic 0x${magic.toString(16).padStart(8, "0")} does not carry the V8 code cache high half 0x${V8_CODE_CACHE_MAGIC_HIGH.toString(16)}; this file is not a V8 code cache.`, + ); + + const declaredPayload = field(5); + const payloadOffset = V8_CODE_CACHE_HEADER_BYTES; + const expectedPayload = bytes.length - payloadOffset; + if (declaredPayload !== expectedPayload) + malformed( + `Header declares a ${String(declaredPayload)}-byte payload but ${String(expectedPayload)} bytes follow the ${String(V8_CODE_CACHE_HEADER_BYTES)}-byte header; the container is truncated or corrupt.`, + ); + + const sourceWord = field(2); + const sourceLength = sourceWord & V8_CODE_CACHE_SOURCE_LENGTH_MASK; + // Bit 30 marks a `vm.SourceTextModule` cache and bit 29 a `vm.compileFunction` one. + // Measured on Node 24.22.1, a 19-unit module source reads `0x40000013` while a script of + // the same length reads `0x00000013`, so the function bit alone reports a module cache as + // a script. Both bits sit outside the source-length mask. + // + // The unflagged case is `unknown`, not `script`: whether V8 marks a function cache is a + // build detail. A `vm.compileFunction` cache reads `0x20000004` on Node 24.22.1 and + // `0x00000004` on Node 22.23.4 for a 4-unit source, so an unflagged word is either + // `vm.Script` or an unmarked function cache, and the container carries nothing that + // separates them. `fields` keeps the observed word so a caller can resolve it against a + // build fingerprint it trusts. + const producer = + (sourceWord & V8_CODE_CACHE_MODULE_BIT) !== 0 + ? ("module" as const) + : (sourceWord & V8_CODE_CACHE_COMPILE_FUNCTION_BIT) !== 0 + ? ("compile_function" as const) + : ("unknown" as const); + + const payload = bytes.subarray(payloadOffset); + const observations: V8CodeCache["embedded_strings"] = []; + let runStart = -1; + const flush = (end: number): void => { + if (runStart < 0) return; + const runBytes = end - runStart; + if (runBytes >= input.min_string_bytes) { + if (observations.length >= input.max_strings) + // The message states the fact. Recovery advice belongs to the provider, which + // can see whether either budget still has headroom before recommending a retry. + throw new V8CodeCacheBudgetFailure( + "max_strings", + input.max_strings, + `Payload contains more than ${String(input.max_strings)} printable runs of at least ${String(input.min_string_bytes)} bytes, so the max_strings observation budget was exhausted before the scan finished.`, + ); + const retained = Math.min( + runBytes, + MAX_INSPECT_V8_CODE_CACHE_STRING_BYTES, + ); + const absolute = payloadOffset + runStart; + observations.push({ + offset: absolute, + bytes: runBytes, + retained_bytes: retained, + truncated: retained < runBytes, + text: payload + .subarray(runStart, runStart + retained) + .toString("latin1"), + }); + } + runStart = -1; + }; + // Indexed reads over the payload view: `payload.entries()` allocates a two-element + // array per byte, which dominated this scan on large caches. + const payloadLength = payload.length; + for (let index = 0; index < payloadLength; index += 1) { + if (isPrintable(payload[index]!)) { + if (runStart < 0) runStart = index; + } else { + flush(index); + } + } + flush(payloadLength); + + return { + artifact, + header: { + magic, + magic_build_stable: magicHigh, + fields: HEADER_ROLES.map((entry, index) => ({ + offset: index * 4, + value: field(index), + role: entry.role, + status: entry.status, + })), + header_bytes: V8_CODE_CACHE_HEADER_BYTES, + source_length: sourceLength, + producer, + }, + payload: { + offset: payloadOffset, + bytes: payload.length, + sha256: payloadSha256(payload), + trailing_bytes: 0, + }, + embedded_strings: observations, + coverage: { + status: "complete", + scanned_bytes: payload.length, + strings: observations.length, + }, + limitations: LIMITATIONS, + }; +}; diff --git a/src/v8/V8CodeCacheProvider.test.ts b/src/v8/V8CodeCacheProvider.test.ts new file mode 100644 index 0000000000..3a19297ac6 --- /dev/null +++ b/src/v8/V8CodeCacheProvider.test.ts @@ -0,0 +1,143 @@ +import { expect, it } from "vitest"; + +import { + MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + MAX_INSPECT_V8_CODE_CACHE_STRINGS, + MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES, + inspectV8CodeCacheInputSchema, +} from "../domain/v8/v8CodeCache.js"; +import { V8CodeCacheBudgetFailure } from "./V8CodeCache.errors.js"; +import { + observationBudgetRecovery, + observationBudgetRemediation, +} from "./V8CodeCacheProvider.js"; + +/** + * The observation budget has two caller-selected limits, each with a ceiling: + * `max_strings` tops out at `MAX_INSPECT_V8_CODE_CACHE_STRINGS` and `min_string_bytes` + * at `MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES`. An admitted file can exceed the + * budget with both already at their maximum, and then no input change is a legal retry. + * + * The recovery is asserted as the decision it is — which retries remain, and whether a + * smaller artifact is the only option — rather than by matching the advice sentence, so + * a reworded message cannot break these and wrong advice cannot pass them. + */ +const input = (overrides: Record = {}) => + inspectV8CodeCacheInputSchema.parse({ + path: "/artifacts/module.jsc", + ...overrides, + }); + +const exhausted = (limit: number) => + new V8CodeCacheBudgetFailure("max_strings", limit, "budget exhausted"); + +it("leaves both retries open while both budgets have headroom", () => { + // The default `min_string_bytes` is the schema minimum, so lowering it is not an + // option here; raising either bound is. + expect(observationBudgetRecovery(exhausted(16), input())).toEqual({ + boundary: "max_strings", + canRaiseMaxStrings: true, + canRaiseMinStringBytes: true, + canLowerMinStringBytes: false, + requiresSmallerArtifact: false, + }); +}); + +it("offers no retry when both budgets are already at their ceilings", () => { + // This is the case the review named: an admitted file with more than 65536 runs of at + // least 256 bytes reaches the branch, and neither suggested retry is admissible. + expect( + observationBudgetRecovery( + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ + max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS, + min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + }), + ), + ).toEqual({ + boundary: "max_strings", + canRaiseMaxStrings: false, + canRaiseMinStringBytes: false, + canLowerMinStringBytes: true, + requiresSmallerArtifact: true, + }); +}); + +it("drops only the run-length retry when min_string_bytes is at its ceiling", () => { + expect( + observationBudgetRecovery( + exhausted(16), + input({ min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES }), + ), + ).toMatchObject({ + canRaiseMaxStrings: true, + canRaiseMinStringBytes: false, + requiresSmallerArtifact: false, + }); +}); + +it("drops only the count retry when max_strings is at its ceiling", () => { + expect( + observationBudgetRecovery( + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS }), + ), + ).toMatchObject({ + canRaiseMaxStrings: false, + canRaiseMinStringBytes: true, + requiresSmallerArtifact: false, + }); +}); + +it("requires a smaller artifact only when the run length cannot drop", () => { + expect( + observationBudgetRecovery( + new V8CodeCacheBudgetFailure( + "min_string_bytes", + 4096, + "budget exhausted", + ), + input({ min_string_bytes: MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES }), + ), + ).toMatchObject({ + boundary: "min_string_bytes", + canLowerMinStringBytes: false, + requiresSmallerArtifact: true, + }); +}); + +/** + * The sentence is rendered from the recovery struct, so what it must be held to is that + * it does not advise a knob the struct closed. Matching the wording would break on a + * reword without catching wrong advice; asserting the branches catches the advice. + */ +it.each([ + [ + "no retry left", + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ + max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS, + min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + }), + ], + [ + "only the run length closed", + exhausted(16), + input({ min_string_bytes: MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES }), + ], + [ + "only the count closed", + exhausted(MAX_INSPECT_V8_CODE_CACHE_STRINGS), + input({ max_strings: MAX_INSPECT_V8_CODE_CACHE_STRINGS }), + ], + ["both open", exhausted(16), input()], +] as const)("never advises a closed budget when %s", (_label, cause, input) => { + const recovery = observationBudgetRecovery(cause, input); + const advice = observationBudgetRemediation(cause, input); + if (!recovery.canRaiseMaxStrings) + expect(advice).not.toMatch(/Raise max_strings/u); + if (!recovery.canRaiseMinStringBytes) + expect(advice).not.toMatch(/Raise min_string_bytes/u); + if (recovery.requiresSmallerArtifact) + expect(advice).toMatch(/smaller artifact/u); +}); diff --git a/src/v8/V8CodeCacheProvider.ts b/src/v8/V8CodeCacheProvider.ts new file mode 100644 index 0000000000..37c60c8551 --- /dev/null +++ b/src/v8/V8CodeCacheProvider.ts @@ -0,0 +1,302 @@ +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + type StableArtifactSnapshot, + readStableArtifact, +} from "../artifacts/readStableArtifact.js"; +import { + artifactFileCancellationCause, + artifactFilePrimaryFacts, + artifactSelectedPathErrno, + type ArtifactFilePrimaryFacts, +} from "../artifacts/ArtifactFileOperation.js"; +import { + projectArtifactCloseFailure, + projectStableArtifactFailure, + stableArtifactInputRecovery, +} from "../artifacts/StableArtifactFailure.js"; +import type { ExecutionOptions } from "../application/AnalysisProvider.js"; +import type { V8CodeCachePort } from "../application/v8CodeCache/V8CodeCachePort.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, +} from "../domain/analysisErrorCore.js"; +import { ArtifactOperationError } from "../domain/artifactOperationError.js"; +import { + MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES, + MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES, + MAX_INSPECT_V8_CODE_CACHE_STRINGS, + MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES, + type InspectV8CodeCacheInput, +} from "../domain/v8/v8CodeCache.js"; +import { err, ok } from "../domain/result.js"; +import { + V8CodeCacheBudgetFailure, + V8CodeCacheFormatFailure, +} from "./V8CodeCache.errors.js"; +import { V8_CODE_CACHE_PROVIDER_IDENTITY } from "./V8CodeCacheProviderIdentity.js"; +import { parseV8CodeCache } from "./V8CodeCacheParser.js"; + +const OPERATION = "inspect_v8_code_cache"; + +/** Portable parser over one bounded, identity-checked regular file. */ +export class V8CodeCacheProvider implements V8CodeCachePort { + readonly identity = V8_CODE_CACHE_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + async close(): Promise { + try { + await this.#resources.close(); + } catch (cause: unknown) { + throw projectArtifactCloseFailure( + cause, + V8_CODE_CACHE_PROVIDER_IDENTITY.id, + OPERATION, + ); + } + } + + async inspect(input: InspectV8CodeCacheInput, options?: ExecutionOptions) { + let snapshot: StableArtifactSnapshot | undefined; + try { + return await this.#resources.run(async () => { + snapshot = await readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ); + return ok( + parseV8CodeCache( + snapshot.bytes, + { + path: input.path, + sha256: snapshot.sha256, + bytes: snapshot.bytes.length, + }, + input, + ), + ); + }); + } catch (cause) { + return err(v8CodeCacheFailure(cause, input, snapshot)); + } + } +} + +const inputBudgetRemediation = ( + cause: ArtifactReaderFailure, + input: InspectV8CodeCacheInput, +): string | undefined => { + if (cause.boundary === "file-size") + return input.max_file_bytes >= MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES + ? `The file exceeds the ${String(MAX_INSPECT_V8_CODE_CACHE_FILE_BYTES)}-byte input ceiling this operation accepts; select a smaller artifact.` + : `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`; + return undefined; +}; + +/** + * Which retries remain after an exhausted observation budget. + * + * `max_strings` tops out at `MAX_INSPECT_V8_CODE_CACHE_STRINGS` and `min_string_bytes` + * between `MIN_…` and `MAX_…`, so an admitted file with more than 65536 runs of 256 + * bytes can exhaust the budget with no admissible input change left. The recovery is + * decided as data, and the advice sentence is rendered from it, so a caller and a test + * can read the choice rather than the wording. + */ +export interface ObservationBudgetRecovery { + readonly boundary: V8CodeCacheBudgetFailure["boundary"]; + /** Whether a higher `max_strings` is still within the schema's admitted range. */ + readonly canRaiseMaxStrings: boolean; + /** Whether a higher `min_string_bytes` is still within the schema's admitted range. */ + readonly canRaiseMinStringBytes: boolean; + /** Whether a lower `min_string_bytes` is still within the schema's admitted range. */ + readonly canLowerMinStringBytes: boolean; + /** Whether the only remaining option is a smaller artifact. */ + readonly requiresSmallerArtifact: boolean; +} + +export const observationBudgetRecovery = ( + cause: V8CodeCacheBudgetFailure, + input: InspectV8CodeCacheInput, +): ObservationBudgetRecovery => { + const canRaiseMaxStrings = + input.max_strings < MAX_INSPECT_V8_CODE_CACHE_STRINGS; + const canRaiseMinStringBytes = + input.min_string_bytes < MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES; + const canLowerMinStringBytes = + input.min_string_bytes > MIN_INSPECT_V8_CODE_CACHE_STRING_BYTES; + if (cause.boundary !== "max_strings") + return { + boundary: cause.boundary, + canRaiseMaxStrings, + canRaiseMinStringBytes, + canLowerMinStringBytes, + requiresSmallerArtifact: !canLowerMinStringBytes, + }; + return { + boundary: cause.boundary, + canRaiseMaxStrings, + canRaiseMinStringBytes, + canLowerMinStringBytes, + requiresSmallerArtifact: !canRaiseMaxStrings && !canRaiseMinStringBytes, + }; +}; + +/** Render the recovery choice for one exhausted observation budget. */ +export const observationBudgetRemediation = ( + cause: V8CodeCacheBudgetFailure, + input: InspectV8CodeCacheInput, +): string => { + const recovery = observationBudgetRecovery(cause, input); + if (recovery.boundary !== "max_strings") + return `Lower min_string_bytes below ${String(cause.limit)} or select a smaller artifact.`; + if (recovery.requiresSmallerArtifact) + return `Both max_strings and min_string_bytes are already at their ceilings (${String(MAX_INSPECT_V8_CODE_CACHE_STRINGS)} observations, ${String(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES)}-byte runs), so no option change clears this budget; select a smaller artifact.`; + if (!recovery.canRaiseMaxStrings) + return `Raise min_string_bytes above ${String(input.min_string_bytes)}; max_strings is already at its ${String(MAX_INSPECT_V8_CODE_CACHE_STRINGS)}-observation ceiling, so a shorter run length cannot help.`; + if (!recovery.canRaiseMinStringBytes) + return `Raise max_strings above ${String(cause.limit)}; min_string_bytes is already at its ${String(MAX_INSPECT_V8_CODE_CACHE_MIN_STRING_BYTES)}-byte ceiling, so a larger artifact must be reduced or split.`; + return `Raise max_strings above ${String(cause.limit)} or raise min_string_bytes above ${String(input.min_string_bytes)}; both are caller-selected observation budgets.`; +}; + +const v8CodeCacheFailurePrimary = ( + facts: ArtifactFilePrimaryFacts, + input: InspectV8CodeCacheInput, +) => { + const { cause, origin } = facts; + if (cause instanceof V8CodeCacheBudgetFailure) + return new AnalysisResourceConstraintError( + OPERATION, + "capacity", + cause.message, + { + max_strings: input.max_strings, + min_string_bytes: input.min_string_bytes, + }, + { + cause, + retryable: false, + remediationAction: observationBudgetRemediation(cause, input), + }, + ); + if (cause instanceof V8CodeCacheFormatFailure) + return cause.kind === "unsupported" + ? new AnalysisUnsupportedTargetError( + OPERATION, + input.path, + cause.message, + { cause }, + ) + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ]); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "integrity" + ) + return new AnalysisArtifactChangedError( + OPERATION, + input.path, + cause.message, + { + cause, + }, + ); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + return new AnalysisCancelledError(OPERATION, { + cause: artifactFileCancellationCause(cause), + }); + if ( + origin === "owned-or-classified" && + cause instanceof ArtifactReaderFailure + ) { + if (cause.reason === "limit") { + const remediationAction = inputBudgetRemediation(cause, input); + return new AnalysisResourceConstraintError( + OPERATION, + cause.boundary ?? "memory", + cause.message, + { + max_file_bytes: input.max_file_bytes, + max_strings: input.max_strings, + }, + { + cause, + ...(remediationAction === undefined + ? {} + : { retryable: false, remediationAction }), + }, + ); + } + if (cause.reason === "path" || cause.reason === "format") + return new AnalysisInputError( + OPERATION, + { cause, ...stableArtifactInputRecovery(cause) }, + [ + { + path: ["path"], + reason: "invalid_format", + message: cause.message, + }, + ], + ); + } + if (cause instanceof Error && "code" in cause) { + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError(OPERATION, input.path, cause.code, { + cause, + }); + const pathErrno = artifactSelectedPathErrno(cause); + if (pathErrno !== undefined) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected V8 code cache could not be read (${pathErrno}): ${input.path}.`, + }, + ]); + } + return new ArtifactOperationError( + OPERATION, + origin === "owned-or-classified" && cause instanceof ArtifactReaderFailure + ? cause.reason + : "io", + undefined, + cause instanceof Error ? cause.message : "V8 code cache inspection failed.", + { cause }, + ); +}; + +const v8CodeCacheFailure = ( + cause: unknown, + input: InspectV8CodeCacheInput, + snapshot?: StableArtifactSnapshot, +) => { + const primary = v8CodeCacheFailurePrimary( + artifactFilePrimaryFacts(cause), + input, + ); + return projectStableArtifactFailure( + cause, + primary, + input.path, + V8_CODE_CACHE_PROVIDER_IDENTITY.id, + OPERATION, + snapshot, + ); +}; diff --git a/src/v8/V8CodeCacheProviderIdentity.ts b/src/v8/V8CodeCacheProviderIdentity.ts new file mode 100644 index 0000000000..56b2f5cd35 --- /dev/null +++ b/src/v8/V8CodeCacheProviderIdentity.ts @@ -0,0 +1,6 @@ +/** Bundled format reader identity; container field roles are separate observed facts. */ +export const V8_CODE_CACHE_PROVIDER_IDENTITY = { + id: "rea-v8-code-cache", + name: "REA V8 code cache reader", + version: "1", +} as const; diff --git a/tests/acceptance/stableArtifactParserFailureCli.test.ts b/tests/acceptance/stableArtifactParserFailureCli.test.ts new file mode 100644 index 0000000000..741a8893d0 --- /dev/null +++ b/tests/acceptance/stableArtifactParserFailureCli.test.ts @@ -0,0 +1,45 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +for (const command of [ + "inspect-python-pyc", + "inspect-erlang-beam", + "inspect-rust-binary", + "inspect-go-binary", + "inspect-elf-notes", + "inspect-devicetree", + "inspect-pe-imports", + "inspect-kernel-module", + "inspect-apple-ips", + "inspect-pe-load-config", + "inspect-pe-certificates", + "inspect-pe-resources", + "inspect-native-exports", + "inspect-binary-hardening", +]) { + cliTest( + `${command} retains the completed file identity after format refusal`, + async ({ cli }) => { + const root = await createTestTempDirectory("rea-format-refusal-"); + const path = join(root, "foreign.bin"); + const bytes = Buffer.alloc(64, 0x7f); + await writeFile(path, bytes); + const result = await cli.run({ arguments: [command, path, "--json"] }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + details: { + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }); + }, + ); +} diff --git a/tests/acceptance/v8CodeCacheCli.test.ts b/tests/acceptance/v8CodeCacheCli.test.ts new file mode 100644 index 0000000000..36e7ca38fa --- /dev/null +++ b/tests/acceptance/v8CodeCacheCli.test.ts @@ -0,0 +1,148 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { toolContract } from "../../src/contracts/toolContracts.js"; +import { v8CodeCacheFixture } from "../../src/v8/V8CodeCache.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +const outputSchema = toolContract("inspect_v8_code_cache").outputSchema; + +cliTest( + "reports the observed header and payload through the public CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-"); + const path = join(root, "module.jsc"); + const bytes = v8CodeCacheFixture( + "function add(a, b) { return a + b; } add(1, 2);", + ); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = outputSchema.parse(result.json); + const report = evidence.normalized_result; + expect(report.header.magic).toBe(bytes.readUInt32LE(0)); + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.fields).toHaveLength(8); + expect(report.payload.offset).toBe(32); + expect(report.payload.bytes).toBe(bytes.length - 32); + expect(report.payload.sha256).toMatch(/^[0-9a-f]{64}$/u); + expect(report.coverage.status).toBe("complete"); + }, +); + +cliTest( + "resolves a relative code cache path against the working directory", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-rel-"); + await writeFile(join(root, "module.jsc"), v8CodeCacheFixture()); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", "module.jsc", "--json"], + cwd: root, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect( + outputSchema.parse(result.json).normalized_result.artifact.path, + ).toBe(join(root, "module.jsc")); + }, +); + +cliTest( + "locates retained printable sequences with byte ranges", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-strings-"); + const path = join(root, "module.jsc"); + const marker = "EMBEDDED_CONFIG_KEY_7c1d"; + const bytes = v8CodeCacheFixture( + `var settings = { key: "${marker}" }; settings.key;`, + ); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: [ + "inspect-v8-code-cache", + path, + "--minStringBytes", + "8", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(0); + const report = outputSchema.parse(result.json).normalized_result; + const observation = report.embedded_strings.find((entry) => + entry.text.includes(marker), + ); + expect(observation).toBeDefined(); + expect(observation!.offset).toBeGreaterThanOrEqual(report.payload.offset); + expect(observation!.offset + observation!.bytes).toBeLessThanOrEqual( + report.artifact.bytes, + ); + }, +); + +cliTest( + "fails closed on a file that is not a V8 code cache", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-cli-foreign-"); + const path = join(root, "random.bin"); + const foreign = Buffer.from(v8CodeCacheFixture()); + foreign.writeUInt32LE(0xdeadbeef, 0); + await writeFile(path, foreign); + const result = await cli.run({ + arguments: ["inspect-v8-code-cache", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "unsupported_target", + details: { + operation: "inspect_v8_code_cache", + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: foreign.length, + sha256: createHash("sha256").update(foreign).digest("hex"), + }, + }, + }); + }, +); + +cliTest( + "retains file identity when a synthetic cache exceeds the string budget", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-v8-budget-"); + const path = join(root, "synthetic.jsc"); + const payload = Buffer.from("aaaa\0bbbb"); + const bytes = Buffer.concat([ + v8CodeCacheFixture().subarray(0, 32), + payload, + ]); + bytes.writeUInt32LE(payload.length, 20); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: [ + "inspect-v8-code-cache", + path, + "--max-strings", + "1", + "--min-string-bytes", + "4", + "--json", + ], + }); + expect(result.exitCode, result.stderr).toBe(1); + expect(result.json).toMatchObject({ + code: "resource_constraint", + details: { + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + }, + }, + }); + }, +); diff --git a/tests/boundary/mcp/absoluteLocalPathTools.test.ts b/tests/boundary/mcp/absoluteLocalPathTools.test.ts index d0f9cfee2b..e3011462bc 100644 --- a/tests/boundary/mcp/absoluteLocalPathTools.test.ts +++ b/tests/boundary/mcp/absoluteLocalPathTools.test.ts @@ -5,6 +5,7 @@ import { expect, it } from "vitest"; import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "../../../src/contracts/javascript/javascriptRecoveryToolContracts.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../../../src/contracts/native/binaryDiagnosticsToolContracts.js"; +import { V8_CODE_CACHE_TOOL_CONTRACTS } from "../../../src/contracts/v8/v8CodeCacheToolContracts.js"; import { WEB_SCRIPT_TOOL_CONTRACTS } from "../../../src/contracts/webScriptToolContracts.js"; import { API_CONTRACT_TOOL_CONTRACTS } from "../../../src/contracts/apiContractToolContracts.js"; import { PLAYWRIGHT_TRACE_TOOL_CONTRACTS } from "../../../src/contracts/playwrightTraceToolContracts.js"; @@ -19,6 +20,7 @@ const CONTRACTS = [ ...API_CONTRACT_TOOL_CONTRACTS, ...PLAYWRIGHT_TRACE_TOOL_CONTRACTS, ...PYTHON_PYC_TOOL_CONTRACTS, + ...V8_CODE_CACHE_TOOL_CONTRACTS, ]; const PATH_FIELDS = { @@ -30,6 +32,7 @@ const PATH_FIELDS = { inspect_devicetree: ["path"], inspect_playwright_trace: ["path"], inspect_python_pyc: ["path"], + inspect_v8_code_cache: ["path"], export_web_scripts: ["capture_path", "output_directory"], recover_javascript_sources: ["path", "output_directory", "additional_paths"], } as const; @@ -129,6 +132,7 @@ it("rejects one relative path field at a time on tools/list", async () => { "inspect_devicetree.path", "inspect_playwright_trace.path", "inspect_python_pyc.path", + "inspect_v8_code_cache.path", "export_web_scripts.capture_path", "export_web_scripts.output_directory", "recover_javascript_sources.path", diff --git a/tests/boundary/mcp/v8CodeCacheMcp.test.ts b/tests/boundary/mcp/v8CodeCacheMcp.test.ts new file mode 100644 index 0000000000..2868a72307 --- /dev/null +++ b/tests/boundary/mcp/v8CodeCacheMcp.test.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createServer } from "../../../src/server/createServer.js"; +import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { v8CodeCacheSchema } from "../../../src/domain/v8/v8CodeCache.js"; +import { v8CodeCacheFixture } from "../../../src/v8/V8CodeCache.fixture.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { mcpTest, type TestMcp } from "../../support/mcp/mcpFixture.js"; + +const completeEvidenceSchema = toolContract( + "inspect_v8_code_cache", +).outputSchema.safeExtend({ normalized_result: v8CodeCacheSchema }); + +const connect = async (mcp: TestMcp) => { + const session = createTestBinarySession(() => { + throw new Error("V8 code cache inspection must not start a deep provider"); + }); + const client = await mcp.connect(createServer({ kind: "session", session })); + return { client, session }; +}; + +mcpTest( + "advertises inspect_v8_code_cache and reports a real container header", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const advertised = (await client.listTools()).tools.find( + (tool) => tool.name === "inspect_v8_code_cache", + ); + expect(advertised?.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: true, + }); + const root = await createTestTempDirectory("rea-v8-mcp-"); + const path = join(root, "module.jsc"); + await writeFile( + path, + v8CodeCacheFixture("function m(){ return 1; } m();"), + ); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError, JSON.stringify(response.content)).not.toBe(true); + const evidence = completeEvidenceSchema.parse(response.structuredContent); + const report = evidence.normalized_result; + expect(report.header.magic_build_stable).toBe(0xc0de); + expect(report.header.header_bytes).toBe(32); + expect(report.payload.offset).toBe(32); + expect(report.payload.offset + report.payload.bytes).toBe( + report.artifact.bytes, + ); + expect(report.limitations.join(" ")).toContain( + "byte-pattern observations", + ); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "requires an absolute path for inspect_v8_code_cache", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path: "module.jsc" }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { operation: "inspect_v8_code_cache" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "reports a foreign magic as an unsupported target rather than a parse", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-v8-unsupported-"); + const path = join(root, "not-a-cache.jsc"); + const foreign = Buffer.from(v8CodeCacheFixture()); + foreign.writeUInt32LE(0xdeadbeef, 0); + await writeFile(path, foreign); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unsupported_target", + details: { operation: "inspect_v8_code_cache" }, + }); + } finally { + await session.close(); + } + }, +); + +mcpTest( + "reports a payload-length disagreement as invalid input", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-v8-malformed-"); + const path = join(root, "truncated.jsc"); + const corrupt = Buffer.from(v8CodeCacheFixture()); + corrupt.writeUInt32LE(corrupt.readUInt32LE(20) + 8, 20); + await writeFile(path, corrupt); + const response = await client.callTool({ + name: "inspect_v8_code_cache", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_v8_code_cache", + partial_observation: { + kind: "file-snapshot-hash", + path, + bytes: corrupt.length, + sha256: createHash("sha256").update(corrupt).digest("hex"), + }, + }, + }); + } finally { + await session.close(); + } + }, +);