Compare commits

..
Author SHA1 Message Date
morlutoandGitHub 75f1bf97e1 Merge branch 'main' into fix/main-sync-2087-captured-c6503ed 2026-10-14 02:47:45 +08:00
morluto a4dd4e8128 Merge commit '8fef2aa64ec1ee742e8b70b825938d30a0ea7319' into HEAD 2026-10-14 02:10:22 +08:00
Codex 346275525e fix(artifacts): bind session inventories to target identity 2026-10-14 02:01:13 +08:00
吴佳浩Albenandmorluto 781827b7b4 fix: repair the static-check failures on the default branch (#2895)
* fix: repair the static-check failures on the default branch

The default branch fails its own required `Static checks` job three ways, so
every pull request reports that job as failed regardless of its own change.

- `npm run format:check` reports `src/domain/functionComparison.test.ts` and
  `src/ida/IdaOperationRunner.ts`.
- `npm run lint` reports a duplicate `import { join } from "node:path"` in
  `tests/boundary/mcp/ghidraInventoryMcp.test.ts` (lines 4 and 17).
- `npm run typecheck` reports `format: "devin"` missing from the client format
  union in `src/application/SupportedClients.ts`, and an unchecked index in
  `tests/boundary/cli/clientProfileLifecycle.test.ts`, whose pair array is
  inferred as `string[][]`.

Apply the pinned formatter, drop the duplicate import, list the `"devin"` format
that its own definition already uses, and mark the pair array `as const`.

Validation: `npm run check:fast` and `npm run format:check` pass.

* fix(setup): keep Devin on the JSON configuration dialect

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
(cherry picked from commit df3d96da80)
2026-10-14 00:53:27 +08:00
morluto dc8a3e6078 fix(artifacts): bind extraction to opened target 2026-10-14 00:46:08 +08:00
morluto 0434be924e fix(artifacts): pass target metadata to format detection 2026-10-14 00:40:12 +08:00
morluto 502266b3f7 chore: merge current main into PR #2816 2026-10-14 00:06:29 +08:00
morluto 1aaaf06928 chore(artifacts): reconcile captured main and preserve iNES history 2026-10-13 23:02:09 +08:00
morluto ee4a11df81 chore(artifacts): preserve contributor iNES merge history 2026-10-13 22:06:37 +08:00
64579525 480a467dac Merge main and preserve iNES evidence format boundaries 2026-10-13 21:46:10 +08:00
morluto 2904bdb4f8 chore: merge captured main into iNES container support 2026-10-13 21:45:12 +08:00
64579525 00f31692b5 test(browser): await discoverable fixture pages before runtime verification 2026-10-13 20:36:35 +08:00
64579525 de2ab343f1 merge: retain main CI planning and artifact checks 2026-10-13 19:37:50 +08:00
64579525 717b5a215e test(javascript): align parameter provenance with main contract 2026-10-13 16:34:28 +08:00
64579525 5e7fcdcdc3 merge: integrate main and retain per-slice FAT alignment 2026-10-13 16:21:36 +08:00
64579525 89914a04cd ci: run Mach-O summary delivery coverage on macOS 2026-10-13 16:11:54 +08:00
64579525 96608517ba fix(artifacts): classify changed admission as integrity failure 2026-10-13 16:07:11 +08:00
64579525 577d6c3e9b test(artifacts): share file read setup and satisfy CI checks 2026-10-13 15:46:10 +08:00
64579525 f6748a511e fix(artifacts): reuse current classification and region reads 2026-10-13 15:38:40 +08:00
64579525 faa6f89566 Merge origin/main and retain inventory-owned extraction routing 2026-10-13 14:50:13 +08:00
64579525 c530a37813 Merge origin/main for artifact admission and evidence fixes 2026-10-13 14:32:52 +08:00
64579525 75c2741912 refactor(artifacts): use JSON-safe inventory producer output directly 2026-10-13 12:21:11 +08:00
64579525 0d018d5a77 chore: preserve current main archive typing and runtime fixes 2026-10-13 12:12:03 +08:00
64579525 9d4c517b84 fix(target): preserve typed cancellation during descriptor admission 2026-10-13 12:05:26 +08:00
64579525 6d0a6b5d51 fix(contracts): share ZIP target formats across MCP lifecycle schemas 2026-10-13 11:55:14 +08:00
64579525 baadb8114c fix: preserve cleanup retries and avoid duplicate inventory parsing 2026-10-13 11:43:58 +08:00
64579525 6c93ebabb0 chore: reconcile iNES support with current main 2026-10-13 11:42:47 +08:00
64579525 2d1d8c45ea test(ci): own iNES macOS coverage and portable fixture paths 2026-10-13 11:04:10 +08:00
64579525 d4530488c3 test(artifacts): verify iNES workflows on macOS 2026-10-13 10:37:48 +08:00
64579525 3590893742 chore: sync main performance verification updates 2026-10-13 10:17:41 +08:00
64579525 72b78e41b9 test(artifacts): admit valid universal inputs before routing 2026-10-13 10:12:17 +08:00
64579525 a74a599fce fix(browser): budget scenario cleanup for content drain and shutdown 2026-10-13 10:00:52 +08:00
64579525 d3e315cf33 Merge remote-tracking branch 'origin/main' into feat/2072-ines-container 2026-10-13 09:36:35 +08:00
64579525 54c2e4e81c chore: reconcile iNES inventory with current main 2026-10-13 09:36:35 +08:00
64579525 c17cb017fa fix(artifacts): complete prefix reads and share ZIP format admission 2026-10-12 23:27:26 +08:00
64579525 2b29105bf9 fix(artifacts): select extraction readers from scanned format 2026-10-12 23:03:51 +08:00
64579525 681f34879e fix(artifacts): prefer artifact magic over implicit database suffix 2026-10-12 22:41:12 +08:00
64579525 a60d60dc6b chore: reconcile documentation with latest main 2026-10-12 22:20:52 +08:00
64579525 1c6217258a chore: merge main and preserve tool effect exceptions 2026-10-12 22:17:04 +08:00
64579525 e47b667670 fix(artifacts): honor cancellation across source validation 2026-10-12 22:06:50 +08:00
64579525 78820dd69a test(browser): wait for popup document loads before capture 2026-10-12 21:50:38 +08:00
64579525 db24a47c15 fix: reconcile iNES inspection with upstream summary delivery 2026-10-12 21:35:46 +08:00
64579525 071f632e92 fix: preserve binary plist detection with extended magic probe 2026-10-12 21:02:02 +08:00
64579525 7cba3acaa4 Merge remote-tracking branch 'origin/main' into feat/2072-ines-container 2026-10-12 20:56:36 +08:00
64579525 ca646b6cb9 test(artifacts): clarify non-executing inspection wording 2026-10-12 20:56:16 +08:00
64579525 2f0e4d79ea fix(artifacts): bind iNES extraction admission to inventory 2026-10-12 17:15:12 +08:00
64579525 9b8916152b fix(artifacts): normalize source loss during root hashing 2026-10-12 16:52:05 +08:00
64579525 e6d4d7fb84 fix(artifacts): validate admitted roots before classification 2026-10-12 16:37:04 +08:00
64579525 5ba8b2f6bf fix(artifacts): revalidate iNES source before parsing header 2026-10-12 16:11:55 +08:00
64579525 574f6bb3bd fix(artifacts): preserve iNES source disappearance as integrity failure 2026-10-12 15:50:02 +08:00
64579525 414307c9d5 feat(artifacts): inspect classic iNES container regions 2026-10-12 15:17:50 +08:00
197 changed files with 3794 additions and 8659 deletions
@@ -122,14 +122,6 @@ explicit path or endpoint and do not need it.
absent metadata does not prove a binary is not Go, and unparsed text records
remain explicit. See the
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md).
- Embedded cargo-auditable Rust dependency metadata: `inspect_rust_binary`
with the explicit local ELF, PE, or thin Mach-O file. This built-in
target-free inspection requires no Rust installation or disassembler. Preserve
package order, dependency indices, and the section file range. A missing
`.dep-v0` section is null metadata, not proof the binary has no dependencies.
A rustc commit hash is an inference from a read-only `/rustc/<40-hex>/` path.
See the
[Rust binary guide](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
- Explicit local SQLite database snapshot: `inspect_sqlite_database`. Inspect
with Node.js 24.x >=24.15 or 26+ providing native SQLite limits. Read schema
@@ -11,18 +11,6 @@ unparsed module-text line. A missing record is an unknown, not proof of a
non-Go binary; observed metadata does not authenticate its claims. Function,
source and type recovery remain a separate native-analysis question.
## Rust dependency metadata
Use `inspect_rust_binary` for an explicit local ELF, PE, or thin Mach-O when
the question concerns the cargo-auditable crate list embedded in `.dep-v0`
(Mach-O: `__DATA,.dep-v0`). No `open_binary`, Rust installation, or target
execution is required. The CLI equivalent is `inspect-rust-binary PATH`.
Preserve artifact SHA-256, package order, dependency indices, and the section
file range. A missing section is null metadata, not proof the binary has no
dependencies. An omitted package kind stays null. A rustc commit hash is
inferred from a read-only `/rustc/<40-hex>/` path and does not by itself
identify the compiler. See [Rust binaries](https://github.com/morluto/rea/blob/main/docs/rust-binaries.md).
## SQLite snapshots
Use `inspect_sqlite_database` with an explicit local database snapshot for its
@@ -225,6 +213,16 @@ REA. Symlinks and encrypted entries are inventory facts, not extractable files.
The result reports the materialized directory; callers do not choose the
destination. No separate permission grant is needed.
For classic iNES cartridges, `open_binary` recognizes the byte signature without
an executable architecture. `inspect_artifact` retains header facts and each
PRG/CHR file unit's offset, length and SHA-256; its nested inventory's
`ines.reconstruction` verifies the actual ordered region bytes against the
whole-file digest. Extraction includes the original header, optional trainer
and uninterpreted trailing bytes. These file units are not mapper/CPU bank
maps. NES 2.0, UNIF, dirty legacy headers and truncated payloads are explicitly
refused. No emulator or 6502 analysis is performed. See the
[iNES guide](https://github.com/morluto/rea/blob/main/docs/ines-containers.md).
Native DMG traversal automatically uses a read-only mount on supported macOS
hosts, with an owned temporary mount directory and cleanup. It needs no approval
flag. Unsupported hosts or mount failures remain explicit limitations; do not
+7
View File
@@ -522,6 +522,12 @@ jobs:
npm run verify:legacy-simulator
npm run verify:apple-dispatch
npm run verify:interface-builder
- name: Verify portable iNES CLI, MCP and source boundaries
run: >-
npm run test:focused --
tests/acceptance/inesContainer.test.ts
tests/boundary/filesystem/inesArtifact.test.ts
tests/boundary/filesystem/inesCancellation.test.ts
- name: Run native Apple and process boundary regressions
run: >-
npm run test:focused --
@@ -533,6 +539,7 @@ jobs:
tests/boundary/cli/nativePlistSelectionFailureCli.test.ts
tests/boundary/cli/nativeDemangleCli.test.ts
tests/boundary/mcp/nativeDemangleMcp.test.ts
tests/boundary/mcp/nativeMachoSummaryMcp.test.ts
tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts
tests/boundary/cli/nativeSignatureCli.test.ts
tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts
-1
View File
@@ -155,7 +155,6 @@ Additional tools and host support depend on the target:
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://rea.tools/guides/native/) |
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier on Linux x64; [offline EVM guide](docs/evm-bytecode.md) |
| SQLite databases | Tables, columns, indexes, views, triggers and selected ordinary-table rows | Local database/WAL snapshot; Node.js 24.x >=24.15 or 26+ with native SQLite limits |
+1 -1
View File
@@ -29,7 +29,6 @@ export default defineConfig({
items: [
{ text: "Native binaries", link: "/native-investigation" },
{ text: "Go build metadata", link: "/go-binaries" },
{ text: "Rust dependencies", link: "/rust-binaries" },
{
text: "JavaScript and Electron",
link: "/javascript-artifact-reconstruction",
@@ -49,6 +48,7 @@ export default defineConfig({
{ text: "Android applications", link: "/android-analysis" },
{ text: "Apple applications", link: "/apple-application-analysis" },
{ text: "Firmware", link: "/firmware-analysis" },
{ text: "iNES containers", link: "/ines-containers" },
{ text: "SQLite databases", link: "/sqlite-databases" },
{ text: "Packet captures", link: "/packet-captures" },
{ text: "Signal recordings", link: "/signal-recordings" },
-1
View File
@@ -243,7 +243,6 @@ REA retains local observation data and removes only URL userinfo credentials, ex
- Console observations with a stack source retain call type, argument types, timestamp, and source location. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache bodies above 64 KiB and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
Frames, resources, scripts, events, and workers outside the selected origin scope are excluded. An explicit `allowed_origins` list can include additional origins; excluded target details are counted without being exposed.
-12
View File
@@ -83,18 +83,6 @@ The shared CLI/MCP workflow reports compiler version, modules, replacements,
build settings, original bytes and SHA-256. See [Go binaries](go-binaries.md)
for supported containers and absent or malformed metadata.
Inspect an embedded cargo-auditable Rust dependency list without installing
Rust or configuring a disassembler:
```bash
rea inspect-rust-binary /absolute/path/to/program --json
```
The same workflow reports package names, versions, sources, the root package,
dependency edges, and the section's file range. A missing `.dep-v0` section is
null metadata, not proof that the binary has no dependencies. See
[Rust binaries](rust-binaries.md).
Configure [Hopper or Ghidra](installation.md#hopper), or the
[IDA adapter](ida-provider.md), before analyzing a native target. Substitute
your target, search text and function name or address in these examples:
+2 -2
View File
@@ -6,8 +6,8 @@ For remote devices, pass a host with an optional port (`host[:port]`; bracket IP
MCP exposes `list_frida_devices`, `list_frida_processes`, `start_frida_session`, `load_frida_script`, `resume_frida_session`, `unload_frida_script`, `frida_session_status`, and `close_frida_session`. A spawned process starts paused so scripts can load first. Closing unloads session scripts and detaches. If a spawn remains paused, REA resumes it only after script cleanup or session detach confirms the instrumentation is released; if both cleanup paths fail, the target stays paused and the cleanup error is retained. REA never kills the target. MCP sessions end with the server process. The one-shot CLI command `instrument-with-frida` accepts one inline script or script file, observes messages for a bounded duration, and returns Evidence with the source hash and captured messages.
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. Server shutdown stops admitting new Frida operations and waits for admitted startup, discovery, and queued work before releasing their resources. A failed cleanup remains reported and can be retried by closing the server again, rather than treating an unregistered session as fully released.
Failed startup cleanup retains acquired Frida handles, including a paused spawn that has not attached, for later cleanup retries. Its remote connection stays leased until session detach and target resume complete. CLI commands await provider cleanup before returning; MCP retains these resources for cleanup when its server closes. A failed cleanup remains reported rather than treating an unregistered session as fully released.
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. A cancelled script load on an existing session releases only its new script before queued operations can run; the session and previously loaded scripts remain available. Once the provider commits a successful load, later request cancellation does not unload that script; use `unload_frida_script` to release it. If script cleanup fails, the script remains owned and a paused target cannot resume until the pending instrumentation is released. Queued script loads, resumes, and unloads check cancellation before dispatching native effects. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
Frida scripts execute inside the selected target with that process's memory, filesystem, and network authority. Message capture is bounded to 16 MiB and reports truncation. Script source is not stored in Evidence; its selected path and SHA-256 digest are. Evidence retains non-authentication target selection such as device or endpoint, mode, program or PID, and spawn arguments. Authentication values remain excluded. Results report the observed session and messages, while unsupported platform metadata remains unknown. A request cancelled before a Frida operation starts has no provider effects. If cancellation arrives during a native Frida call, REA waits for that call to return, then closes any session it created and returns the observed session as a partial observation. Cancellation Evidence separately reports whether a target resume was observed, whether instrumentation cleanup completed, and that final target liveness remains unknown. REA does not kill the target. Remote discovery retains its observations and reports a separate cleanup error if the temporary connection cannot be removed.
The [Frida Core API](https://frida.re/docs/frida-core-api/) documents device management, sessions, and script loading; the [JavaScript API](https://frida.re/docs/javascript-api/) documents target-side instrumentation. [Luma](https://luma.frida.re/) is Frida's standalone GUI; REA does not automate or control it. `npm run verify:frida` requires a Linux host with the optional Frida binding and uses a short-lived local Node.js fixture created by the verifier. Add `-- --address HOST:PORT --pid PID` to run the separate remote attach lane against a caller-selected target. Mock tests do not establish real device/server compatibility.
+1 -1
View File
@@ -49,7 +49,7 @@ For runtime instrumentation, see the [Frida guide](./frida-instrumentation.md).
| Task | Guides |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [Rust dependencies](rust-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Inspect native functions, references, or types | [Native binaries](native-investigation.md), [Go metadata](go-binaries.md), [offline ELF layout](binary-diagnostics.md), [recorded crashes](recorded-crashes.md) |
| Select a native provider or import format | [Ghidra semantics](ghidra-provider.md), [Windows](windows-ghidra-p0.md), [DOS](ghidra-dos.md), [raw bytes](raw-binary.md), [MIPS](ghidra-mips.md), [PSP](ghidra-psp.md), [NativeAOT](ghidra-nativeaot.md), [IDA](ida-provider.md), [JEB](jeb-analysis.md) |
| Analyze JavaScript or Electron artifacts | [Static analysis](javascript-artifact-reconstruction.md), [tracing and comparisons](javascript-application-workflows.md), [application graph](javascript-application-graph.md), [source recovery](javascript-recovery.md) |
| Compare static JavaScript with runtime observations | [Runtime attribution](javascript-runtime-observation.md), [reconciliation](javascript-runtime-reconciliation.md), [Electron observation](electron-observation.md) |
+72
View File
@@ -0,0 +1,72 @@
# Classic iNES container inspection
REA recognizes classic iNES from the `NES` + `0x1a` byte signature, independently
of the file extension. This read-only container inspection uses portable
Node.js file reads; it needs neither Ghidra nor an emulator.
```bash
rea inspect-artifact ./cartridge.nes --json
rea extract-artifact ./cartridge.nes --json
```
Through MCP, call `open_binary` with the local path, then `inspect_artifact`.
Use `extract_artifact` to materialize the regions in the fresh directory named
by its result. The caller owns that successful extraction directory.
## Header and file regions
Inspection returns `container-header` and `reconstruction` observations inline.
The single inventory Evidence under `substeps[].evidence.normalized_result`
contains the complete graph and an `ines` object:
- `ines.header`: original 16-byte `header_hex`, PRG/CHR file-unit counts, mapper,
raw flags 6/7, trainer and battery flags, and the declared mirroring mode.
- `occurrences[]`: each region's path, `source_location.offset` and
`source_location.length` in the original container. Follow `artifact_id` to
the node's observed SHA-256 and byte count.
- `ines.reconstruction`: ordered region paths, concatenated byte count,
SHA-256 and `verified: true`. REA accumulates the concatenation digest during
the same physical-order region scan that produces node hashes, then compares
it with the original whole-file digest. Final source freshness is also checked.
No reconstructed file is written during inspection.
Regions are `header.bin`, optional `trainer.bin` (512 bytes),
`prg/000.bin` onward (16 KiB each), `chr/000.bin` onward (8 KiB each), and
optional `trailing.bin`. Every input byte is retained, including unused header
fields and uninterpreted trailing data. Zero CHR-ROM units create no CHR region;
REA does not fabricate CHR-RAM contents.
After extraction, concatenate the materialized files in
`ines.reconstruction.region_order` from the inspection inventory. Their bytes
reproduce the input exactly. Graph path order is lexical and is not physical
file order; use the explicit region order or source offsets.
## Supported boundary
This increment supports classic iNES with a complete 16-byte header, nonzero
PRG count, complete declared trainer/PRG/CHR payload, and zero reserved bytes
12–15. Dirty legacy headers are refused because their upper mapper bits are
ambiguous; REA does not silently repair them. NES 2.0 and UNIF signatures,
unsupported header variants and truncated payloads are explicitly refused.
An arbitrary file named `.nes` is not admitted as iNES.
The PRG/CHR units describe file storage, not mapper bank sizes or CPU windows.
The mapper number and flags are header declarations, not proof of cartridge
hardware or runtime behavior. Bank switching, 6502/65816 disassembly, emulator
sessions and interpretation of console-specific trailing data remain outside
this lane. Raw-binary Ghidra analysis is a separate workflow. This container
increment addresses the first milestone of [#2072](https://github.com/morluto/rea/issues/2072);
the related [#1641](https://github.com/morluto/rea/issues/1641) tracks 6502/65816
native analysis with explicit load addresses and provider limitations. Extracted
PRG units still need caller-selected CPU windows before static code analysis;
their physical file offsets do not establish those addresses.
Source-owned byte fixtures verify original offsets and hashes, extraction and
byte-exact reconstruction, CLI/stdio MCP parity, malformed/unsupported inputs,
failed target-switch recovery, cancellation and source identity. These checks
do not establish emulator behavior or compatibility with every historical ROM.
The portable CLI/stdio MCP acceptance and source-identity checks run in Linux
and macOS CI. The same workflows have also been executed on native Windows x64.
Format references: [iNES](https://www.nesdev.org/wiki/INES) and
[NES 2.0](https://www.nesdev.org/wiki/NES_2.0).
-3
View File
@@ -764,9 +764,6 @@ errors expose Error names, messages, and available codes under
truncation markers. Darwin ownership-helper compilation failures also retain
the compiler outcome, exit code or signal, and bounded stderr; a timed-out
compiler remains distinguishable from a compiler that exited with an error.
The helper compile deadline defaults to 120,000 ms; set
`REA_SWIFTC_COMPILE_TIMEOUT_MS` to an integer between 1 and 2,147,483,647 when
a busy host needs longer.
Other rejection values retain their primitive value or an explicit type.
Shutdown warnings include the failure
kind, message, and diagnostics, while process and temporary-project cleanup
+2 -28
View File
@@ -139,40 +139,14 @@ The result preserves the requested names in `selection.type_names` and reports
unmatched names separately. If metadata names are unreadable, limitations retain
that uncertainty. With no selection, `selection` is null and whole-assembly
inspection keeps its existing resource policy. Selection restricts types,
fields, methods, call edges, field accesses and user strings; member references
remain an assembly-wide inventory, so output size is not guaranteed to be small.
fields, methods, call edges and field accesses; member references remain an
assembly-wide inventory, so output size is not guaranteed to be small.
Coverage describes the selected members. Comparisons leave members outside the
opposite observation's selection unknown, and application graphs report their
selected scope as partial. Neither makes an absence claim about the whole
assembly. Older member Evidence without `selection` remains readable.
### User strings
`ldstr` anchors keep their build-local `0x70` tokens. `user_strings.literals`
lists each distinct token loaded by an inspected method body once, in token
order, with its `#US` file offset and decoded value. Values keep their stored
UTF-16 code units, including unpaired surrogates. Token `0x70000000` names the
heap's empty entry at offset 0 and reads as `""`; any other item there is
malformed. The terminal flag is checked against ECMA-335 II.24.2.4, and the
length prefix against the width II.23.2 assigns. A difference is reported with
its byte offset as a user-string issue while preserving the readable literal.
Toolchain conventions can differ; this conformance observation does not
establish runtime rejection. A missing `#US` stream is one stream-level issue;
a malformed item is reported against its token. User strings carry their own
coverage: any of these makes `user_strings.state` partial and lists the issue
in `user_strings.issues`, while the member `coverage` and `metadata.status` are
unaffected, and readable literals remain. The state is also partial when an
inspected method's CIL was not fully decoded (a malformed exception section
alone does not count) or a method that may belong to the inspected types was
not inspected (its row could not be read, or its type's name could not be read
under a `type_names` selection), because `ldstr` instructions there were not
observed. Normalized CIL digests do not include decoded values.
`compare_managed_members` does not decode or compare literals: an unchanged
method does not establish unchanged strings, and the comparison says so in its
limitations. Older member Evidence without `user_strings` reads as `null`: not
examined.
### Edge target declaring types
`member_refs[].parent_name`, `call_edges[].target_declaring_type`, and
-26
View File
@@ -21,31 +21,6 @@ directly to `compare_functions`, and `inspect_artifact` Evidence to
`compare_artifacts`. Use `get_evidence_bundle` when the task needs broader
retained session history or an explicit bundle for transfer.
Artifact inventories declare `coverage.scope` (`root` or `container-entries`)
and `coverage.status` (`complete` or `unknown`) independently of diagnostic
wording. Comparison requires matching scopes, complete graph members and complete
traversal coverage before concluding equality. A fully observed root without
container children can be `unchanged`; its scope notice stays in `limitations`
without creating a gap. Content and executable metadata changes still produce
root deltas. Missing graph members yield `truncated`; unknown traversal coverage
or unavailable occurrence hashes retain uncertainty. Integrity mismatches remain
contradictions. Static artifact equality does not establish unchanged runtime
behavior.
Exhausted traversals with deliberately omitted filesystem entries retain
`unknown` coverage and identify each omitted path/type in `limitations`; FIFO,
socket and device entries are not opened. Case-distinct archive members remain
separate observations. Their case-insensitive materialization diagnostic does
not reduce traversal coverage when both spellings were read.
Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations.
The coverage field is optional when reading older retained Evidence. The owning
inventory parser recognizes historical REA scope notices for compatibility;
unfamiliar legacy limitations remain conservative. Typed records do not derive
coverage from limitation text. Pages from one inventory must carry the same
coverage declaration.
Function dossiers can declare per-facet coverage in the optional
`normalized_result.facet_availability` map. Each supplied facet is either
`{ "available": true }` or `{ "available": false, "reason": "..." }`.
@@ -499,7 +474,6 @@ These deadlines have different owners:
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
| Hopper startup | REA allows 120,000 ms by default; `REA_HOPPER_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. Startup failure is reported by the first provider query. |
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid supplied values fail configuration validation. Startup failure is reported by the first provider query. |
| Darwin swiftc helper | REA allows 120,000 ms by default for ownership-helper compilation; `REA_SWIFTC_COMPILE_TIMEOUT_MS` accepts decimal integers from 1–2,147,483,647 ms. Invalid values fail configuration validation. |
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
For an already connected client using the pinned SDK, request options are the
-1
View File
@@ -135,7 +135,6 @@ npx rea-agents@latest setup
| الملفات الثنائية للشيفرة الأصلية | شيفرة شبه برمجية، وتعليمات تجميع، وسلاسل نصية، ورموز، واستدعاءات، ومراجع | Hopper أو Ghidra أو IDA؛ [تحليل الشيفرة الأصلية](https://rea.tools/ar/guides/native/) |
| بنية ELF دون تشغيل | الأقسام والمقاطع والرموز ومعلومات إعادة التموضع الأصلية، وآليات الحماية المحتملة المستدلّ عليها بالفحص الثابت | pwntools يوفّره المستدعي على Linux x64؛ [تشخيص الملفات الثنائية](../binary-diagnostics.md) |
| شيفرة EVM البايتية | محددات التوجيه، وإزاحات البايتات، والمعاملات المستنتجة، وقابلية تغيير الحالة | مدخل محلي يحمل بايتات خامًا أو تمثيلًا سداسيًا عشريًا على Linux x64؛ [دليل EVM دون تشغيل](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| أعطال Linux المسجّلة | سجلات note الخام، ومسجّلات المعالج/الإشارات لكل خيط مسجّل، وترشيحات اختيارية لخرائط الذاكرة | pwntools يوفّره المستدعي على Linux x64؛ GDB/pwndbg اختياريان؛ [الأعطال المسجّلة](../recorded-crashes.md) |
| JavaScript / Electron | الوحدات، وعمليات الاستيراد، وخرائط المصدر، والمسارات، وIPC، وعلاقات إضافات الشيفرة الأصلية | Node.js وnpm؛ [تحليل التطبيقات](https://rea.tools/ar/guides/javascript/) |
| المواقع | بنية الصفحة، والسكربتات، وملاحظات الشبكة، ولقطات الشاشة المطلوبة | متصفح من عائلة Chrome؛ [تحليل المتصفح](https://rea.tools/ar/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA benötigt Node.js 22.x (>=22.19), 24.x (>=24.11) oder 26+ sowie npm. Zusätz
| Native Binärdateien | Pseudocode, Assemblercode, Zeichenketten, Symbole, Aufrufe und Verweise | Hopper, Ghidra oder IDA; [native Analyse](https://rea.tools/de/guides/native/) |
| Offline-ELF-Struktur | Sektionen, Segmente, ursprüngliche Symbole/Relokationen und mögliche Schutzmaßnahmen aus statischer Analyse | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; [Binärdiagnostik](../binary-diagnostics.md) |
| EVM-Bytecode | Dispatch-Selektoren, Byte-Offsets, abgeleitete Argumente und Veränderbarkeit des Zustands | Lokale Eingabe mit Rohbytes oder Hexdarstellung unter Linux x64; [Offline-EVM-Leitfaden](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Aufgezeichnete Linux-Abstürze | Unverarbeitete Note-Einträge, Register/Signale aller aufgezeichneten Threads und optionale Zuordnungskandidaten | Vom Aufrufer bereitgestelltes pwntools unter Linux x64; optional GDB/pwndbg; [aufgezeichnete Abstürze](../recorded-crashes.md) |
| JavaScript / Electron | Module, Imports, Source Maps, Routen, IPC und Beziehungen zu nativen Add-ons | Node.js und npm; [Anwendungsanalyse](https://rea.tools/de/guides/javascript/) |
| Websites | Seitenstruktur, Skripte, Netzwerkbeobachtungen und angeforderte Screenshots | Ein Browser aus der Chrome-Familie; [Browseranalyse](https://rea.tools/de/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA requiere Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, además de npm. Las h
| Binarios nativos | Pseudocódigo, ensamblador, cadenas, símbolos, llamadas y referencias | Hopper, Ghidra o IDA; [análisis nativo](https://rea.tools/es/guides/native/) |
| Estructura ELF sin ejecutar | Secciones, segmentos, símbolos/reubicaciones originales y posibles medidas de mitigación detectadas mediante análisis estático | pwntools proporcionado por quien llama, en Linux x64; [diagnóstico de binarios](../binary-diagnostics.md) |
| Bytecode EVM | Selectores de despacho, desplazamientos en bytes, argumentos inferidos y mutabilidad | Entrada local con bytes sin procesar o representación hexadecimal, en Linux x64; [guía de EVM sin ejecutar](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Fallos de Linux registrados | Registros note sin procesar, registros/señales de cada hilo registrado y posibles correspondencias de memoria opcionales | pwntools proporcionado por quien llama, en Linux x64; GDB/pwndbg opcionales; [fallos registrados](../recorded-crashes.md) |
| JavaScript / Electron | Módulos, importaciones, mapas de código fuente, rutas, IPC y relaciones con complementos nativos | Node.js y npm; [análisis de aplicaciones](https://rea.tools/es/guides/javascript/) |
| Sitios web | Estructura de la página, scripts, observaciones de red y capturas de pantalla solicitadas | Un navegador de la familia Chrome; [análisis del navegador](https://rea.tools/es/guides/browser/) |
-1
View File
@@ -127,7 +127,6 @@ REA به Node.js 22.x (>=22.19)، 24.x (>=24.11) یا 26+، به‌همراه np
| باینری‌های بومی | شبه‌کد، اسمبلی، رشته‌ها، نمادها، فراخوانی‌ها و ارجاعات | Hopper، Ghidra یا IDA؛ [تحلیل بومی](https://rea.tools/fa/guides/native/) |
| ساختار ELF آفلاین | بخش‌ها، سگمنت‌ها، نمادها و relocationهای اصلی و گزینه‌های احتمالی کاهش آسیب‌پذیری ایستا | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ [تشخیص باینری](../binary-diagnostics.md) |
| بایت‌کد EVM | selectorهای dispatch، offsetهای بایتی، آرگومان‌های استنباط‌شده و قابلیت تغییر وضعیت | دادهٔ خام/hex محلی روی Linux x64؛ [راهنمای EVM آفلاین](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| کرش‌های ثبت‌شدهٔ لینوکس | یادداشت‌های خام، رجیسترها/سیگنال‌های تمام threadهای ثبت‌شده و گزینه‌های احتمالی نگاشت | pwntools تأمین‌شده توسط فراخواننده روی Linux x64؛ GDB/pwndbg اختیاری؛ [کرش‌های ثبت‌شده](../recorded-crashes.md) |
| JavaScript / Electron | ماژول‌ها، importها، source mapها، مسیرها، IPC و ارتباط افزونه‌های بومی | Node.js و npm؛ [تحلیل برنامه](https://rea.tools/fa/guides/javascript/) |
| وب‌سایت‌ها | ساختار صفحه، اسکریپت‌ها، مشاهدات شبکه و اسکرین‌شات‌های درخواستی | مرورگر مبتنی بر Chrome؛ [تحلیل مرورگر](https://rea.tools/fa/guides/browser/) |
-1
View File
@@ -139,7 +139,6 @@ REA nécessite Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, ainsi que npm. Les
| Structure ELF hors ligne | Sections, segments, symboles et relocations d'origine, et protections statiques candidates | pwntools fourni par l'utilisateur sous Linux x64 ; [diagnostics binaires](../binary-diagnostics.md) |
| Artefact WASM | Validation, sections, formes WAT exactes d'import/export et WAT décodé | WABT 1.0.42 fourni par l'utilisateur ; `inspect_wasm_artifact` |
| Bytecode EVM | Sélecteurs de dispatch, offsets en octets, arguments inférés et mutabilité | Fichier local brut ou hexadécimal sous Linux x64 ; [guide EVM hors ligne](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Crashs Linux enregistrés | Notes brutes, registres et signaux de chaque thread enregistré, et correspondances de mappings facultatives | pwntools fourni par l'utilisateur sous Linux x64 ; GDB/pwndbg en option ; [crashs enregistrés](../recorded-crashes.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC et relations avec les modules natifs | Node.js et npm ; [analyse d'applications](https://rea.tools/fr/guides/javascript/) |
| Sites web | Structure des pages, scripts, observations réseau et captures d'écran demandées | Un navigateur de la famille Chrome ; [analyse du navigateur](https://rea.tools/fr/guides/browser/) |
-1
View File
@@ -142,7 +142,6 @@ Alat tambahan dan dukungan sistem host bergantung pada target yang dianalisis:
| Tata letak ELF offline | Bagian, segmen, simbol/relokasi asli, dan kandidat mitigasi statis | pwntools yang disediakan pemanggil pada Linux x64; [diagnostik biner](../binary-diagnostics.md) |
| Artefak WASM | Validasi, bagian, bentuk WAT import/ekspor yang tepat, dan WAT yang didekode | WABT 1.0.42 yang disediakan pemanggil; `inspect_wasm_artifact` |
| Bytecode EVM | Selector dispatch, offset byte, argumen yang disimpulkan, dan sifat mutabilitas | Pembawa data raw/hex lokal; [panduan EVM offline](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Crash Linux yang direkam | Catatan mentah, register/sinyal setiap thread yang direkam, dan kandidat pemetaan opsional | pwntools yang disediakan pemanggil; GDB/pwndbg opsional; [crash yang direkam](../recorded-crashes.md) |
| JavaScript / Electron | Modul, impor, source map, rute, IPC, dan hubungan add-on native | Node.js dan npm; [analisis aplikasi](https://rea.tools/id/guides/javascript/) |
| Situs web | Struktur halaman, skrip, pengamatan jaringan, dan tangkapan layar yang diminta | Peramban berbasis Chrome; [analisis peramban](https://rea.tools/id/guides/browser/) |
-1
View File
@@ -139,7 +139,6 @@ REA richiede Node.js 22.x (>=22.19), 24.x (>=24.11) o 26+, oltre a npm. Strument
| Layout ELF offline | Sezioni, segmenti, simboli/relocazioni originali e candidati mitigazioni statiche | pwntools fornito dal chiamante su Linux x64; [diagnostica binaria](../binary-diagnostics.md) |
| Artefatto WASM | Convalida, sezioni, forme WAT di import/export esatte e WAT decodificato | WABT 1.0.42 fornito dal chiamante; `inspect_wasm_artifact` |
| Bytecode EVM | Selettori di dispatch, offset dei byte, argomenti inferiti e mutabilità | Carrier raw/hex locale; [guida EVM offline](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Crash Linux registrati | Note grezze, registri/segnai di ogni thread registrato e candidati mapping opzionali | pwntools fornito dal chiamante; GDB/pwndbg opzionale; [crash registrati](../recorded-crashes.md) |
| JavaScript / Electron | Moduli, import, source map, route, IPC e relazioni con add-on nativi | Node.js e npm; [analisi delle applicazioni](https://rea.tools/it/guides/javascript/) |
| Siti web | Struttura della pagina, script, osservazioni di rete e screenshot richiesti | Un browser della famiglia Chrome; [analisi browser](https://rea.tools/it/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA には Node.js 22.x(>=22.19)、24.x(>=24.11)、または 26+ と npm
| ネイティブバイナリ | 疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照 | Hopper、Ghidra、または IDA;[ネイティブ分析](https://rea.tools/ja/guides/native/) |
| オフライン ELF レイアウト | セクション、セグメント、元のシンボル/再配置情報、静的解析によるセキュリティ対策の候補 | Linux x64 で利用者が用意する pwntools;[バイナリ診断](../binary-diagnostics.md) |
| EVM バイトコード | ディスパッチセレクター、バイトオフセット、推定された引数と状態変更の可否 | Linux x64 上のローカルの生バイト/16 進数の入力ファイル;[オフライン EVM ガイド](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 記録済みの Linux クラッシュ | 生の note レコード、記録された全スレッドのレジスター/シグナル、任意のマッピング候補 | Linux x64 で利用者が用意する pwntools;GDB/pwndbg は任意;[記録済みクラッシュ](../recorded-crashes.md) |
| JavaScript / Electron | モジュール、インポート、ソースマップ、ルート、IPC、ネイティブアドオンの関係 | Node.js と npm;[アプリケーション分析](https://rea.tools/ja/guides/javascript/) |
| ウェブサイト | ページ構造、スクリプト、ネットワークの観測結果、依頼されたスクリーンショット | Chrome 系ブラウザー;[ブラウザー分析](https://rea.tools/ja/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA에는 Node.js 22.x(>=22.19), 24.x(>=24.11) 또는 26+와 npm이 필요합니
| 네이티브 바이너리 | 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조 | Hopper, Ghidra 또는 IDA; [네이티브 분석](https://rea.tools/ko/guides/native/) |
| 오프라인 ELF 레이아웃 | 섹션, 세그먼트, 원본 심볼/재배치 정보, 정적 분석으로 얻은 보안 완화 기법 후보 | Linux x64에서 호출자가 제공하는 pwntools; [바이너리 진단](../binary-diagnostics.md) |
| EVM 바이트코드 | 디스패치 선택자, 바이트 오프셋, 추론한 인자와 상태 변경 가능 여부 | Linux x64의 로컬 원시 바이트/16진수 입력 파일; [오프라인 EVM 가이드](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 기록된 Linux 크래시 | 원시 note 레코드, 기록된 모든 스레드의 레지스터/시그널, 선택적 매핑 후보 | Linux x64에서 호출자가 제공하는 pwntools; GDB/pwndbg는 선택 사항; [기록된 크래시](../recorded-crashes.md) |
| JavaScript / Electron | 모듈, 임포트, 소스 맵, 라우트, IPC, 네이티브 애드온 관계 | Node.js와 npm; [애플리케이션 분석](https://rea.tools/ko/guides/javascript/) |
| 웹사이트 | 페이지 구조, 스크립트, 네트워크 관찰 결과, 요청한 스크린샷 | Chrome 계열 브라우저; [브라우저 분석](https://rea.tools/ko/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA wymaga Node.js 22.x (>=22.19), 24.x (>=24.11) lub 26+ oraz npm. Dodatkowe na
| Natywne pliki binarne | Pseudokod, kod asemblera, ciągi znaków, symbole, wywołania i odwołania | Hopper, Ghidra lub IDA; [analiza kodu natywnego](https://rea.tools/pl/guides/native/) |
| Układ pliku ELF w trybie offline | Sekcje, segmenty, oryginalne symbole/relokacje i potencjalne mechanizmy zabezpieczeń wskazane przez analizę statyczną | pwntools dostarczone przez użytkownika w systemie Linux x64; [diagnostyka plików binarnych](../binary-diagnostics.md) |
| Kod bajtowy EVM | Selektory wywołań, przesunięcia bajtowe, wywnioskowane argumenty i mutowalność | Lokalny plik z surowymi bajtami lub zapisem szesnastkowym w systemie Linux x64; [przewodnik EVM offline](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Zarejestrowane awarie systemu Linux | Surowe rekordy note, rejestry/sygnały każdego zarejestrowanego wątku i opcjonalni kandydaci na mapowania | pwntools dostarczone przez użytkownika w systemie Linux x64; opcjonalnie GDB/pwndbg; [zarejestrowane awarie](../recorded-crashes.md) |
| JavaScript / Electron | Moduły, importy, mapy źródeł, trasy, IPC i powiązania z natywnymi dodatkami | Node.js i npm; [analiza aplikacji](https://rea.tools/pl/guides/javascript/) |
| Strony internetowe | Struktura strony, skrypty, obserwacje sieciowe i zamówione zrzuty ekranu | Przeglądarka z rodziny Chrome; [analiza w przeglądarce](https://rea.tools/pl/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ O REA requer Node.js 22.x (>=22.19), 24.x (>=24.11) ou 26+, além do npm. As fer
| Binários nativos | Pseudocódigo, assembly, strings, símbolos, chamadas e referências | Hopper, Ghidra ou IDA; [análise nativa](https://rea.tools/pt-BR/guides/native/) |
| Estrutura ELF sem execução | Seções, segmentos, símbolos/relocações originais e possíveis mecanismos de mitigação identificados por análise estática | pwntools fornecido pelo chamador no Linux x64; [diagnóstico de binários](../binary-diagnostics.md) |
| Bytecode EVM | Seletores de despacho, offsets de bytes, argumentos inferidos e mutabilidade | Entrada local com bytes brutos ou representação hexadecimal no Linux x64; [guia de EVM sem execução](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Falhas do Linux registradas | Registros note brutos, registradores/sinais de cada thread registrada e candidatos opcionais a mapeamentos | pwntools fornecido pelo chamador no Linux x64; GDB/pwndbg opcionais; [falhas registradas](../recorded-crashes.md) |
| JavaScript / Electron | Módulos, importações, source maps, rotas, IPC e relações com complementos nativos | Node.js e npm; [análise de aplicativos](https://rea.tools/pt-BR/guides/javascript/) |
| Sites | Estrutura da página, scripts, observações de rede e capturas de tela solicitadas | Um navegador da família Chrome; [análise do navegador](https://rea.tools/pt-BR/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA требует Node.js 22.x (>=22.19), 24.x (>=24.11) или 26+, а так
| Нативные бинарные файлы | Псевдокод, ассемблер, строки, символы, вызовы и ссылки | Hopper, Ghidra или IDA; [нативный анализ](https://rea.tools/ru/guides/native/) |
| Структура ELF без запуска | Секции, сегменты, исходные символы/релокации и возможные механизмы защиты по данным статического анализа | pwntools, предоставленный вызывающей стороной, в Linux x64; [диагностика бинарных файлов](../binary-diagnostics.md) |
| Байт-код EVM | Селекторы диспетчеризации, смещения байтов, предполагаемые аргументы и изменяемость состояния | Локальные необработанные байты или шестнадцатеричное представление в Linux x64; [руководство по EVM без запуска](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Записанные сбои Linux | Необработанные записи note, регистры/сигналы каждого записанного потока и необязательные кандидаты сопоставления с памятью | pwntools, предоставленный вызывающей стороной, в Linux x64; необязательные GDB/pwndbg; [записанные сбои](../recorded-crashes.md) |
| JavaScript / Electron | Модули, импорты, карты исходного кода, маршруты, IPC и связи с нативными дополнениями | Node.js и npm; [анализ приложений](https://rea.tools/ru/guides/javascript/) |
| Сайты | Структура страницы, скрипты, наблюдения за сетью и запрошенные снимки экрана | Браузер семейства Chrome; [анализ браузера](https://rea.tools/ru/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA ต้องใช้ Node.js 22.x (>=22.19), 24.x (>=24.11) หรือ 26
| ไบนารีเนทีฟ | โค้ดเทียม แอสเซมบลี สตริง สัญลักษณ์ การเรียก และการอ้างอิง | Hopper, Ghidra หรือ IDA; [การวิเคราะห์เนทีฟ](https://rea.tools/th/guides/native/) |
| โครงสร้าง ELF แบบออฟไลน์ | เซกชัน เซกเมนต์ สัญลักษณ์/รีโลเคชันต้นฉบับ และตัวเลือกกลไกป้องกันที่พบจากการวิเคราะห์แบบสถิต | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; [การวินิจฉัยไบนารี](../binary-diagnostics.md) |
| ไบต์โค้ด EVM | ตัวเลือกฟังก์ชันสำหรับการเรียก ออฟเซ็ตไบต์ อาร์กิวเมนต์และความสามารถในการเปลี่ยนสถานะที่อนุมานได้ | ไฟล์บนเครื่องที่มีไบต์ดิบหรือข้อมูลเลขฐานสิบหกบน Linux x64; [คู่มือ EVM แบบออฟไลน์](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| ข้อมูลการแครชของ Linux ที่บันทึกไว้ | ระเบียน note ดิบ รีจิสเตอร์/สัญญาณของทุกเธรดที่บันทึกไว้ และตัวเลือกการแมปเพิ่มเติม | pwntools ที่ผู้ใช้จัดเตรียมบน Linux x64; เลือกใช้ GDB/pwndbg เพิ่มเติมได้; [การแครชที่บันทึกไว้](../recorded-crashes.md) |
| JavaScript / Electron | โมดูล การนำเข้า ซอร์สแมป เส้นทาง IPC และความสัมพันธ์กับส่วนเสริมเนทีฟ | Node.js และ npm; [การวิเคราะห์แอปพลิเคชัน](https://rea.tools/th/guides/javascript/) |
| เว็บไซต์ | โครงสร้างหน้า สคริปต์ ข้อมูลเครือข่ายที่สังเกตได้ และภาพหน้าจอที่ร้องขอ | เบราว์เซอร์ในตระกูล Chrome; [การวิเคราะห์ผ่านเบราว์เซอร์](https://rea.tools/th/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA потребує Node.js 22.x (>=22.19), 24.x (>=24.11) або 26+, а та
| Нативні бінарні файли | Псевдокод, асемблерний код, рядки, символи, виклики й посилання | Hopper, Ghidra або IDA; [нативний аналіз](https://rea.tools/uk/guides/native/) |
| Структура ELF без запуску | Секції, сегменти, початкові символи/релокації та можливі захисні механізми за даними статичного аналізу | pwntools, наданий стороною, що викликає інструмент, у Linux x64; [діагностика бінарних файлів](../binary-diagnostics.md) |
| Байт-код EVM | Селектори диспетчеризації, зміщення байтів, виведені припущення про аргументи та змінюваність стану | Локальні необроблені байти або шістнадцяткове представлення у Linux x64; [посібник EVM без запуску](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Записані аварійні завершення в Linux | Необроблені записи note, регістри/сигнали кожного записаного потоку й необов'язкові кандидати зіставлення пам'яті | pwntools, наданий стороною, що викликає інструмент, у Linux x64; необов'язкові GDB/pwndbg; [записані аварійні завершення](../recorded-crashes.md) |
| JavaScript / Electron | Модулі, імпорти, карти вихідного коду, маршрути, IPC і зв'язки з нативними доповненнями | Node.js і npm; [аналіз застосунків](https://rea.tools/uk/guides/javascript/) |
| Сайти | Структура сторінки, скрипти, мережеві спостереження й запитані знімки екрана | Браузер сімейства Chrome; [аналіз браузера](https://rea.tools/uk/guides/browser/) |
-1
View File
@@ -135,7 +135,6 @@ REA yêu cầu Node.js 22.x (>=22.19), 24.x (>=24.11) hoặc 26+, cùng với np
| Tệp nhị phân mã máy | Mã giả, hợp ngữ, chuỗi, ký hiệu, lời gọi và tham chiếu | Hopper, Ghidra hoặc IDA; [phân tích mã máy](https://rea.tools/vi/guides/native/) |
| Bố cục ELF ngoại tuyến | Các section, segment, ký hiệu/thông tin tái định vị gốc và các biện pháp giảm thiểu có thể có từ phân tích tĩnh | pwntools do bên gọi cung cấp trên Linux x64; [chẩn đoán tệp nhị phân](../binary-diagnostics.md) |
| Bytecode EVM | Bộ chọn điều phối, độ lệch byte, tham số suy luận và khả năng thay đổi trạng thái | Đầu vào cục bộ chứa byte thô hoặc dạng thập lục phân trên Linux x64; [hướng dẫn EVM ngoại tuyến](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| Sự cố Linux đã ghi lại | Bản ghi note thô, thanh ghi/tín hiệu của mọi luồng được ghi lại và các ứng viên ánh xạ tùy chọn | pwntools do bên gọi cung cấp trên Linux x64; GDB/pwndbg tùy chọn; [sự cố đã ghi lại](../recorded-crashes.md) |
| JavaScript / Electron | Mô-đun, các khai báo import, source map, tuyến, IPC và quan hệ với tiện ích mở rộng mã máy | Node.js và npm; [phân tích ứng dụng](https://rea.tools/vi/guides/javascript/) |
| Trang web | Cấu trúc trang, script, quan sát mạng và ảnh chụp màn hình được yêu cầu | Trình duyệt thuộc họ Chrome; [phân tích trình duyệt](https://rea.tools/vi/guides/browser/) |
-1
View File
@@ -139,7 +139,6 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| Go 二進位檔案 | 內嵌編譯器版本、模組、替換項和建置設定 | 內建靜態 ELF/PE/thin Mach-O 檢查;無需安裝 Go |
| WASM 工件 | 驗證、節、精確的匯入/匯出 WAT 形式及解碼後的 WAT | 呼叫方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
| EVM 位元組碼 | 分派選擇器、位元組偏移、推斷的參數和狀態可變性 | Linux x64 上的本地原始位元組/十六進位輸入載體;[離線 EVM 指南](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 已記錄的 Linux 崩潰 | 原始 note 記錄、每個已記錄執行緒的暫存器/訊號及可選的對映候選項 | Linux x64 上由呼叫方提供的 pwntools;可選的 GDB/pwndbg;[已記錄崩潰](../recorded-crashes.md) |
| JavaScript / Electron | 模組、匯入、source map、路由、IPC 和原生附加元件關係 | Node.js 和 npm;[應用分析](https://rea.tools/zh-TW/guides/javascript/) |
| 網站 | 頁面結構、腳本、網路觀察結果和按請求獲取的螢幕擷取畫面 | Chrome 系瀏覽器;[瀏覽器分析](https://rea.tools/zh-TW/guides/browser/) |
-1
View File
@@ -139,7 +139,6 @@ REA 需要 Node.js 22.x(>=22.19)、24.x(>=24.11)或 26+,以及 npm。
| Go 二进制文件 | 内嵌编译器版本、模块、替换项和构建设置 | 内置静态 ELF/PE/thin Mach-O 检查;无需安装 Go |
| WASM 工件 | 验证、节、精确的导入/导出 WAT 形式及解码后的 WAT | 调用方提供的 WABT 1.0.42;`inspect_wasm_artifact` |
| EVM 字节码 | 分派选择器、字节偏移、推断的参数和状态可变性 | Linux x64 上的本地原始字节/十六进制输入载体;[离线 EVM 指南](../evm-bytecode.md) |
| Rust binaries | Embedded cargo-auditable dependency list | [Rust binaries](../rust-binaries.md) |
| 已记录的 Linux 崩溃 | 原始 note 记录、每个已记录线程的寄存器/信号及可选的映射候选项 | Linux x64 上由调用方提供的 pwntools;可选的 GDB/pwndbg;[已记录崩溃](../recorded-crashes.md) |
| JavaScript / Electron | 模块、导入、source map、路由、IPC 和原生扩展关系 | Node.js 和 npm;[应用分析](https://rea.tools/zh-CN/guides/javascript/) |
| 网站 | 页面结构、脚本、网络观察结果和按请求获取的截图 | Chrome 系浏览器;[浏览器分析](https://rea.tools/zh-CN/guides/browser/) |
-67
View File
@@ -1,67 +0,0 @@
# Rust binary dependency metadata
`inspect_rust_binary` / `inspect-rust-binary` answers which crate names,
versions, sources, and dependency edges are embedded by cargo-auditable in one
selected local executable or library. It reads the file without running it and
requires no Rust installation, active binary target, Hopper, Ghidra, or IDA.
```bash
rea inspect-rust-binary /absolute/path/to/application --json
```
The CLI resolves relative paths against the caller's working directory. MCP
requires an absolute filesystem path and preserves that exact selection:
```json
{
"name": "inspect_rust_binary",
"arguments": { "path": "/artifacts/application" }
}
```
## Observations
The inline Evidence result contains the artifact path, SHA-256, byte length,
container format, architecture, word size, and byte order. When a `.dep-v0`
section is present, `auditable_metadata` contains:
- `section_name`, `.dep-v0` for ELF and PE or `__DATA,.dep-v0` for thin Mach-O;
- `file_offset` and `section_bytes`, the exact file range of the compressed section;
- `compressed_bytes` and `uncompressed_bytes`;
- `format_version`, the embedded integer, or `null` when the field is omitted;
- `packages`, in the section's original order, with `name`, `version`, `source`,
`kind`, `dependencies`, and `root`;
- `root_package_index`, the single package marked `root`, or `null` when none is marked;
- `dependency_edges`, pairs of package indices in package-then-dependency order;
- `cyclic_dependency_indices`, sorted unique indices that participate in a cycle.
An omitted `kind` stays `null`. The embedded format treats that omission as
runtime, and that default is not stored in the section. An omitted dependency
list is an empty array. An omitted `root` flag is false.
`rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in
read-only data. Its `evidence_kind` is `inferred`. The hash is not a claim that
that compiler produced the binary. Several distinct hashes leave a single
commit unknown.
## Limits and failures
The selected file is limited to 256 MiB. A dependency section is limited to 1
MiB compressed. Inflated JSON is limited to the smaller of 8 MiB and 64 times
the compressed size. The complete report is limited to 8 MiB. Empty sections,
truncated or trailing zlib bytes, invalid JSON, unknown fields, dependency
indices outside the package list, more than one root package, and compressed
dependency sections are typed failures. A cycle is reported and is not walked.
A missing section returns success with `auditable_metadata: null` and a
limitation. Absence of `.dep-v0` does not mean the binary has no dependencies.
Embedded names and versions are file content. They do not authenticate the
packages or the compiler.
Universal Mach-O containers are unsupported. Select a thin slice first. ELF,
PE, and thin Mach-O are the supported containers.
Package and dependency counts are checked against the minimum required JSON
representation before graph allocation or schema cloning. Oversized graphs
report the output byte budget; the compiler-path capacity reports
`maximum_rustc_hashes` in hashes, independently of byte limits.
+3 -18
View File
@@ -61,24 +61,9 @@ The tool profile preserves upstream release/source identity, resolved executable
paths, executable digests, exact banners and arguments. Executable digests are
checked again after each command. Caller-supplied tools are trusted programs;
version and digest recording are not publisher authentication or an OS sandbox.
Each subprocess has a 30-second deadline and 32 MiB combined output budget.
Inspection output past the budget fails with `resource_constraint`; an oversized
`--version` probe still reports `unreadable_output`. Both streams share the
budget, so a stream that starts after the other has filled it retains nothing.
The captured output keeps up to the first 4,096 bytes that each stream retained,
each stream's retained byte count, and the exit code and signal. Retained text
can end in U+FFFD where the budget cut a multi-byte character.
These excerpts bound the returned diagnostic text.
[PR #2117](https://github.com/morluto/rea/pull/2117) reports elapsed time, CPU
time, and peak process-family memory, including WABT children, for warm Linux
x64 WABT 1.0.42 CLI workflows; those costs did not change outside measurement
noise. Agent token usage and truly cold workflows remain
unmeasured. The underlying error cause still retains the full captured
subprocess snapshot, so the excerpt does not establish lower retained memory
or faster analysis.
All commands use existing owned-process cancellation and cleanup. Workspace
cleanup failures remain explicit errors with resource paths.
Each subprocess has a 30-second deadline and 32 MiB combined output budget;
truncation fails. All commands use existing owned-process cancellation and
cleanup. Workspace cleanup failures remain explicit errors with resource paths.
WAT is a decoded representation, not original source or proof of runtime
behavior. Custom annotations can preserve payloads, but WAT does not establish original
+7
View File
@@ -4,6 +4,12 @@
export const SCOPES = {
// Apple artifact CLI/MCP lane on macOS, including its focused regressions.
apple: [
"src/artifacts/InesArtifactReader.ts",
"src/domain/inesContainer.ts",
"tests/fixtures/ines.ts",
"tests/acceptance/inesContainer.test.ts",
"tests/boundary/filesystem/inesArtifact.test.ts",
"tests/boundary/filesystem/inesCancellation.test.ts",
"src/native/Apple*",
"src/native/NativeMacOS*",
"src/native/NativeUi*",
@@ -19,6 +25,7 @@ export const SCOPES = {
"tests/boundary/cli/nativePlistSelectionFailureCli.test.ts",
"tests/boundary/cli/nativeDemangleCli.test.ts",
"tests/boundary/mcp/nativeDemangleMcp.test.ts",
"tests/boundary/mcp/nativeMachoSummaryMcp.test.ts",
"tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts",
"tests/boundary/cli/nativeSignatureCli.test.ts",
"tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts",
+55 -10
View File
@@ -16,17 +16,28 @@ import { compareBrowserScenarios } from "../../dist/domain/browserScenarioDiff.j
import { browserScenarioCaptureSchema } from "../../dist/domain/browserScenarioCapture.js";
import { parseEvidence } from "../../dist/domain/evidence.js";
// Readiness includes descendant document loads, so complete network capture
// does not depend on whether an inline script runs before an iframe finishes.
const document = (path) => {
const icon = '<link rel="icon" href="data:,">';
if (path.startsWith("/nested"))
return `<script>throw new Error("nested popup failure")</script>
<script>opener.postMessage("nested-ready", "*")</script>`;
return `${icon}<script>throw new Error("nested popup failure")</script>
<script>addEventListener("load", () => opener.postMessage("nested-ready", "*"))</script>`;
if (path.startsWith("/popup"))
return `<script>throw new Error("popup failure")</script>
<script>onmessage = e => {
if (e.data === "nested-ready") opener.postMessage("popup-ready", "*");
}; window.open("/nested");</script><iframe src="/child"></iframe>`;
if (path.startsWith("/child")) return "child frame";
return `<script>onmessage = e => {
return `${icon}<script>throw new Error("popup failure")</script>
<script>
let loaded = false, nested = false;
const ready = () => {
if (loaded && nested) opener.postMessage("popup-ready", "*");
};
addEventListener("load", () => { loaded = true; ready(); });
onmessage = e => {
if (e.data === "nested-ready") { nested = true; ready(); }
};
window.open("/nested");
</script><iframe src="/child"></iframe>`;
if (path.startsWith("/child")) return `${icon}child frame`;
return `${icon}<script>onmessage = e => {
if (e.data === "popup-ready") document.body.dataset.ready = "true";
}</script><button onclick="window.open('/redirect')">Open popup</button>`;
};
@@ -74,7 +85,25 @@ const assertNetwork = (result, origin) => {
1,
`${kind} ${path} must be captured exactly once`,
);
assert.equal(result.completeness.equality_eligible, true);
assert.equal(
result.completeness.equality_eligible,
true,
JSON.stringify({
limitations: result.limitations,
unfinished: result.events.items.filter(
(event) => event.kind === "request-unfinished",
),
}),
);
assert.equal(
result.events.items.filter(
(event) =>
event.kind === "request-finished" &&
event.url?.url === origin + "/child",
).length,
1,
"child document must finish before network capture ends",
);
assert.equal(compareWithSelf(result).overall_status, "unchanged");
};
@@ -179,13 +208,27 @@ const verifyTransports = async (executable, origin) => {
/** Verify popup timing, selection, coverage, and shared-context isolation on Chromium. */
export async function verifyPopupEventCoverage(executable) {
let slowChild = true;
let childTimer;
const childStarted = Promise.withResolvers();
const server = createServer((request, response) => {
if (request.url === "/redirect") {
response.writeHead(302, { Location: "/popup" });
response.end();
} else {
response.setHeader("Content-Type", "text/html");
response.end(document(request.url ?? "/"));
if (slowChild && request.url === "/child") {
// Keep one response open while the nested page can announce readiness.
// This exposes premature capture cutoff even on fast local browsers.
response.writeHead(200);
response.write(document("/child"));
childStarted.resolve();
childTimer = setTimeout(() => response.end(), 1500);
} else if (slowChild && request.url === "/nested") {
void childStarted.promise.then(() => response.end(document("/nested")));
} else {
response.end(document(request.url ?? "/"));
}
}
});
server.listen(0, "127.0.0.1");
@@ -198,6 +241,7 @@ export async function verifyPopupEventCoverage(executable) {
await capture(scenarioFor(executable, origin, ["network"])),
origin,
);
slowChild = false;
const errors = await capture(
scenarioFor(executable, origin, ["page-errors"]),
);
@@ -239,6 +283,7 @@ export async function verifyPopupEventCoverage(executable) {
stdio_mcp: true,
};
} finally {
clearTimeout(childTimer);
server.closeAllConnections();
await new Promise((resolve, reject) =>
server.close((error) =>
@@ -12,10 +12,13 @@ import {
BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
waitForBrowserDevtoolsPort,
} from "../../dist/browser/BrowserProcessStartup.js";
import { discoverCdpEndpoint } from "../../dist/browser/CdpEndpoint.js";
import { BrowserObservationError } from "../../dist/domain/browserObservationError.js";
/** Launch the synthetic fixture browser and await CDP in its own verified process group. */
/** Admit the owned fixture only after CDP serves its requested page. */
export async function startRuntimeFixtureBrowser(executable, origin) {
const profile = await mkdtemp(join(tmpdir(), "rea-real-web-runtime-"));
const deadline = performance.now() + BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS;
let owned;
try {
owned = await spawnOwnedProviderProcess({
@@ -53,7 +56,16 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
stderr: () => stderr,
// Match the broader real-browser verifier's allowance for cold Chrome
// startup on shared runners; early process failures still reject at once.
timeoutMs: BROWSER_DEVTOOLS_STARTUP_TIMEOUT_MS,
timeoutMs: Math.max(1, deadline - performance.now()),
});
// Port publication can precede a responsive discovery service and renderer.
// Both phases share the owned fixture's startup budget; public CDP calls
// retain their normal per-response deadlines and failure semantics.
await waitForRuntimeFixturePage({
child: owned.process,
port,
origin,
deadline,
});
return {
port,
@@ -68,17 +80,86 @@ export async function startRuntimeFixtureBrowser(executable, origin) {
},
};
} catch (cause) {
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
await rm(profile, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
await closeRuntimeFixtureResources(
[
async () => {
// Keep the profile when stopping its browser cannot be confirmed.
if (owned !== undefined) await stopOwnedBrowser(owned, profile);
await rm(profile, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
},
],
cause,
);
throw cause;
}
}
async function waitForRuntimeFixturePage({ child, port, origin, deadline }) {
const controller = new AbortController();
let lastFailure;
const onExit = () =>
controller.abort(
new Error(
"Owned runtime fixture exited before CDP discovery became ready",
{ cause: lastFailure },
),
);
const onError = (cause) => controller.abort(cause);
const timer = setTimeout(
() =>
controller.abort(
new Error(
"Owned runtime fixture page was not discoverable within its startup budget",
{ cause: lastFailure },
),
),
Math.max(1, deadline - performance.now()),
);
child.once("exit", onExit);
child.once("error", onError);
try {
for (;;) {
if (child.exitCode !== null || child.signalCode !== null) onExit();
controller.signal.throwIfAborted();
try {
const discovery = await discoverCdpEndpoint(
`http://127.0.0.1:${port}`,
"list_browser_targets",
controller.signal,
);
if (
discovery.targets.some(
(target) =>
target.type === "page" &&
(target.url === origin || target.url.startsWith(`${origin}/`)),
)
)
return;
} catch (cause) {
controller.signal.throwIfAborted();
// Only transient startup failures are retryable. Invalid protocol
// responses and payload-limit failures must remain visible immediately.
if (
!(cause instanceof BrowserObservationError) ||
!["timeout", "endpoint_unreachable"].includes(cause.reason)
)
throw cause;
lastFailure = cause;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
} finally {
clearTimeout(timer);
child.removeListener("exit", onExit);
child.removeListener("error", onError);
}
}
async function stopOwnedBrowser(owned, profile) {
const child = owned.process;
const exited =
-5
View File
@@ -28,7 +28,6 @@ export const toolFamilyCatalog = (sources) => {
...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
...sources.evmContracts.EVM_TOOL_CONTRACTS,
...sources.goContracts.GO_TOOL_CONTRACTS,
...sources.rustContracts.RUST_TOOL_CONTRACTS,
...sources.wasmContracts.WASM_TOOL_CONTRACTS,
...sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
...sources.captureContracts.CAPTURE_TOOL_CONTRACTS,
@@ -226,10 +225,6 @@ export const providerCatalog = (sources) => {
identity: sources.goProvider.GO_BINARY_PROVIDER_IDENTITY,
contracts: sources.goContracts.GO_TOOL_CONTRACTS,
},
{
identity: sources.rustProvider.RUST_BINARY_PROVIDER_IDENTITY,
contracts: sources.rustContracts.RUST_TOOL_CONTRACTS,
},
{
identity: sources.sqliteDomain.SQLITE_PROVIDER_IDENTITY,
contracts: sources.sqliteContracts.SQLITE_TOOL_CONTRACTS,
-2
View File
@@ -59,8 +59,6 @@ const SOURCE_PATHS = {
evmProvider: "dist/evm/EvmoleRelease.js",
goContracts: "dist/contracts/go/goToolContracts.js",
goProvider: "dist/go/GoBinaryProvider.js",
rustContracts: "dist/contracts/rust/rustToolContracts.js",
rustProvider: "dist/rust/RustBinaryProvider.js",
artifactContracts: "dist/contracts/artifactToolContracts.js",
managedContracts: "dist/contracts/managed/managedToolContracts.js",
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
-1
View File
@@ -8,7 +8,6 @@ const MIGRATED_PROVIDER_ROOTS = new Set([
"sqlite",
"evm",
"go",
"rust",
"wasm",
"captures",
"signals",
-6
View File
@@ -8,10 +8,4 @@ export function assertLinuxPackageProviderFailure(execution: {
export function verifyPackagePlatform(input: {
readonly cli: string;
readonly environment: Readonly<Record<string, string | undefined>>;
readonly platform?: string;
readonly runCommand?: (
command: string,
arguments_: readonly string[],
environment: Readonly<Record<string, string | undefined>>,
) => Promise<string>;
}): Promise<void>;
+15 -29
View File
@@ -1,13 +1,8 @@
import { json, run, runWithStatus } from "./lib/verify-package-core.mjs";
/** Run platform-specific deep-analysis smoke tests. */
export async function verifyPackagePlatform({
cli,
environment,
platform = process.platform,
runCommand = run,
}) {
if (platform === "linux") {
export async function verifyPackagePlatform({ cli, environment }) {
if (process.platform === "linux") {
const unsupportedExecution = await runWithStatus(
cli,
["analyze", process.execPath, "--json"],
@@ -16,27 +11,25 @@ export async function verifyPackagePlatform({
assertLinuxPackageProviderFailure(unsupportedExecution);
} else {
const overview = json(
await runCommand(
cli,
["analyze", process.execPath, "--json"],
environment,
),
await run(cli, ["analyze", process.execPath, "--json"], environment),
);
if (!hasPositiveProcedureCount(overview))
if (
overview.operation !== "binary_overview" ||
overview.normalized_result?.procedure_count < 1
)
throw new Error(
`packaged Hopper-backed analyze CLI failed: ${JSON.stringify(overview)}`,
);
const inspected = json(
await runCommand(
cli,
["inspect", process.execPath, "--json"],
environment,
),
await run(cli, ["inspect", process.execPath, "--json"], environment),
);
if (!hasPositiveProcedureCount(inspected))
if (
inspected.operation !== "binary_overview" ||
inspected.normalized_result?.procedure_count < 1
)
throw new Error("packaged inspect CLI failed");
const functionResult = json(
await runCommand(
await run(
cli,
["function", process.execPath, "0x1000", "--json"],
environment,
@@ -51,7 +44,7 @@ export async function verifyPackagePlatform({
`packaged function CLI failed: ${JSON.stringify(functionResult)}`,
);
const xrefs = json(
await runCommand(
await run(
cli,
["xrefs", process.execPath, "0x1000", "--json"],
environment,
@@ -63,7 +56,7 @@ export async function verifyPackagePlatform({
)
throw new Error(`packaged xrefs CLI failed: ${JSON.stringify(xrefs)}`);
const trace = json(
await runCommand(
await run(
cli,
["trace", process.execPath, "fixture", "--json"],
environment,
@@ -100,10 +93,3 @@ export const assertLinuxPackageProviderFailure = (execution) => {
`packaged Linux Hopper verification did not fail closed: ${JSON.stringify(failure)}`,
);
};
const hasPositiveProcedureCount = (result) =>
typeof result === "object" &&
result !== null &&
result.operation === "binary_overview" &&
Number.isInteger(result.normalized_result?.procedure_count) &&
result.normalized_result.procedure_count > 0;
+61 -120
View File
@@ -1,18 +1,17 @@
import { readFileHandlePrefix } from "../filesystem/readFileHandlePrefix.js";
import { constants, type Stats } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
lstat,
realpath,
stat,
type FileHandle,
} from "node:fs/promises";
nesContainerFormat,
parseInesLayout,
} from "../domain/inesContainer.js";
import { access, realpath, stat, type FileHandle } from "node:fs/promises";
import { extname, isAbsolute, resolve } from "node:path";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import {
openRegularFile,
sameRegularFileState,
verifyRegularFileState,
} from "../filesystem/RegularFile.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
@@ -40,14 +39,10 @@ import {
} from "../domain/zipPackageFormat.js";
import {
parseCodeViewData,
parseExecutableHeader,
parsePeRecord,
peDebugDirectoryEntry,
peRvaToFileOffset,
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
/** Options for resolving a local binary target and its stable identity. */
export interface BinaryTargetResolverOptions {
@@ -166,15 +161,17 @@ export const parseBinaryTarget = async (
availableArchitectures: ["x86"],
});
}
// Probe WASM content before suffix-only database routing; an explicit
// Probe content before suffix-only database routing; an explicit
// database selection keeps the caller's chosen interpretation.
const artifactFormat =
targetKind === "database"
? undefined
: await detectArtifactFormat(path, handle, signal);
: await detectArtifactFormat(path, handle, metadata, signal);
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
(targetKind === undefined &&
artifactFormat === undefined &&
path.toLowerCase().endsWith(".hop"))
)
return ok({
path,
@@ -216,6 +213,13 @@ export const parseBinaryTarget = async (
await handle.close();
}
} catch (cause: unknown) {
// The filesystem admission helper throws the caller's abort reason after
// confirming descriptor release. Cleanup failures carry a different owner
// error and must remain visible rather than being reduced to cancellation.
if (signal?.aborted === true && cause === signal.reason)
return err(
new AnalysisCancelledError("resolve_binary_target", { cause }),
);
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof BinaryTargetError) return err(cause);
if (!(cause instanceof Error)) throw cause;
@@ -268,9 +272,26 @@ const throwIfTargetResolutionCancelled = (signal?: AbortSignal): void => {
throw new AnalysisCancelledError("resolve_binary_target");
};
/** Preserve cancellation when an asynchronous identity check succeeds or rejects. */
const verifyTargetState = async (
path: string,
handle: FileHandle,
initial: Stats,
signal?: AbortSignal,
): Promise<void> => {
try {
await verifyRegularFileState(path, { handle, initial });
} catch (cause: unknown) {
throwIfTargetResolutionCancelled(signal);
throw cause;
}
throwIfTargetResolutionCancelled(signal);
};
const detectArtifactFormat = async (
path: string,
handle: FileHandle,
initial: Stats,
signal?: AbortSignal,
): Promise<
| Exclude<
@@ -288,32 +309,44 @@ const detectArtifactFormat = async (
> => {
throwIfTargetResolutionCancelled(signal);
const lower = path.toLowerCase();
const magic = Buffer.alloc(8);
const observed = await handle.read(magic, 0, magic.length, 0);
const magic = await readFileHandlePrefix(handle, 16, () =>
throwIfTargetResolutionCancelled(signal),
);
throwIfTargetResolutionCancelled(signal);
await verifyTargetState(path, handle, initial, signal);
if (
observed.bytesRead === 8 &&
magic.equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))
magic.length >= 8 &&
magic.subarray(0, 8).equals(Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]))
)
throw new BinaryTargetError(
path,
"WebAssembly v1 artifacts are not supported by the native opening route",
{ constraint: "wasm_requires_static_inspection" },
);
if (hasZipSignature(magic.subarray(0, observed.bytesRead))) {
const prefix = magic;
const nesFormat = nesContainerFormat(prefix);
if (nesFormat !== undefined) {
const parsed = parseInesLayout(prefix, initial.size);
throwIfTargetResolutionCancelled(signal);
if (!parsed.ok)
throw new BinaryTargetError(path, parsed.error, {
constraint: "file_format_rejected",
});
return "ines";
}
if (hasZipSignature(prefix)) {
// The HarmonyOS App Pack suffix `.app` is a root-input concept; a nested
// ZIP member ending in `.app` keeps the generic zip classification.
if (lower.endsWith(".app")) return "app-pack";
return zipPackageFormatForPath(lower) ?? "zip";
}
// Binary plists, including keyed archives, often use other extensions.
if (hasBinaryPlistSignature(magic.subarray(0, observed.bytesRead)))
return "plist";
if (hasBinaryPlistSignature(magic)) return "plist";
const named = namedArtifactFormat(lower);
if (named !== undefined) return named;
if (
lower.endsWith(".pkg") &&
observed.bytesRead >= 4 &&
magic.length >= 4 &&
magic.subarray(0, 4).equals(Buffer.from("xar!"))
)
return "pkg";
@@ -354,7 +387,7 @@ const isArchiveFormat = (
| "raw-binary"
>,
): format is Extract<BinaryTarget, { kind: "archive" }>["format"] =>
isZipPackageFormat(format) || ["asar", "dmg", "pkg"].includes(format);
isZipPackageFormat(format) || ["asar", "ines", "dmg", "pkg"].includes(format);
const sha256Handle = async (
handle: FileHandle,
@@ -381,12 +414,8 @@ const sha256Handle = async (
throw new BinaryTargetError(path, "target grew while being read");
}
throwIfTargetResolutionCancelled(signal);
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
if (
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath) ||
position !== initial.size
)
await verifyTargetState(path, handle, initial, signal);
if (position !== initial.size)
throw new BinaryTargetError(path, "target changed while being read");
return hash.digest("hex");
};
@@ -553,98 +582,10 @@ const readPeMetadata = async (
return err(`unsupported ${windowsHeader.signature} executable in MZ image`);
const optionalHeaderSize = fileHeader.readUInt16LE(20);
if (optionalHeaderSize > 4096) return err("invalid PE optional header size");
const numberOfSections = fileHeader.readUInt16LE(6);
const record = Buffer.alloc(24 + optionalHeaderSize);
const recordRead = await handle.read(record, 0, record.length, offset);
throwIfTargetResolutionCancelled(signal);
if (recordRead.bytesRead !== record.length)
return err("invalid or truncated PE header");
const codeView = await readPeCodeView(
handle,
offset,
numberOfSections,
record.subarray(24),
optionalHeaderSize,
signal,
);
return parsePeRecord(record, codeView);
};
const readPeCodeView = async (
handle: FileHandle,
peOffset: number,
numberOfSections: number,
optionalHeader: Buffer,
optionalHeaderSize: number,
signal?: AbortSignal,
): Promise<PeCodeViewRecord | undefined> => {
throwIfTargetResolutionCancelled(signal);
const debugDir = peDebugDirectoryEntry(optionalHeader);
if (debugDir === undefined || numberOfSections > 96) return undefined;
const sectionTableOffset = peOffset + 24 + optionalHeaderSize;
const sectionTableSize = numberOfSections * 40;
const sectionTable = Buffer.alloc(sectionTableSize);
const sectionRead = await handle.read(
sectionTable,
0,
sectionTableSize,
sectionTableOffset,
);
throwIfTargetResolutionCancelled(signal);
if (sectionRead.bytesRead !== sectionTableSize) return undefined;
const debugFileOffset = peRvaToFileOffset(
debugDir.debugRva,
sectionTable,
numberOfSections,
debugDir.sizeOfHeaders,
Math.min(debugDir.debugSize, 4096),
);
if (debugFileOffset === undefined) return undefined;
const maxDebugSize = Math.min(debugDir.debugSize, 4096);
const debugDirectoryBuffer = Buffer.alloc(maxDebugSize);
const debugDirRead = await handle.read(
debugDirectoryBuffer,
0,
maxDebugSize,
debugFileOffset,
);
throwIfTargetResolutionCancelled(signal);
const numEntries = Math.floor(debugDirRead.bytesRead / 28);
for (let j = 0; j < numEntries; j++) {
const entryOffset = j * 28;
const type = debugDirectoryBuffer.readUInt32LE(entryOffset + 12);
if (type !== 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW
const sizeOfData = debugDirectoryBuffer.readUInt32LE(entryOffset + 16);
let pointerToRawData = debugDirectoryBuffer.readUInt32LE(entryOffset + 24);
if (pointerToRawData === 0) {
const addressOfRawData = debugDirectoryBuffer.readUInt32LE(
entryOffset + 20,
);
const mapped = peRvaToFileOffset(
addressOfRawData,
sectionTable,
numberOfSections,
debugDir.sizeOfHeaders,
Math.min(sizeOfData, 4096),
);
if (mapped === undefined) continue;
pointerToRawData = mapped;
}
if (sizeOfData < 16) continue;
const maxCodeViewRead = Math.min(sizeOfData, 4096);
const codeViewBuffer = Buffer.alloc(maxCodeViewRead);
const codeViewRead = await handle.read(
codeViewBuffer,
0,
maxCodeViewRead,
pointerToRawData,
);
throwIfTargetResolutionCancelled(signal);
const parsed = parseCodeViewData(
codeViewBuffer.subarray(0, codeViewRead.bytesRead),
sizeOfData,
);
if (parsed !== undefined) return parsed;
}
return undefined;
return recordRead.bytesRead === record.length
? parsePeRecord(record)
: err("invalid or truncated PE header");
};
+1 -1
View File
@@ -343,7 +343,7 @@ const workflowAvailabilityFor = ({
kind,
policy,
}: AvailabilityContext): Availability | null => {
if (name === "inspect_go_binary" || name === "inspect_rust_binary")
if (name === "inspect_go_binary")
return { reason: "available", remediation: null };
if (name === "inspect_sqlite_database") {
const capability = sqliteDatabaseCapability(process.versions.node);
@@ -186,28 +186,6 @@ describe("analysis provider registry: binding failures and cancellation", () =>
expect(explicit.error.reason).toBe("target_unsupported");
});
it("rejects an unknown environment preference even when every candidate rejects the target", async () => {
const alpha = candidate("alpha");
const misconfigured = new AnalysisProviderRegistry(
[alpha.provider],
"missing",
);
for (const selected of [
await misconfigured.select(ARTIFACT_TARGET),
await misconfigured.select(ARTIFACT_TARGET, "missing"),
]) {
if (selected.ok) throw new Error("expected unknown provider rejection");
expect(projectAnalysisError(selected.error)).toMatchObject({
details: {
selection_reason: "unknown_provider",
requested_provider_id: "missing",
candidate_ids: ["alpha"],
},
});
}
});
it("does not bind a profile with unresolved version or adapter failure", async () => {
const unresolved = candidate("unresolved", { profile: "missing" });
const failed = candidate("failed", { profile: "error" });
@@ -176,7 +176,7 @@ export class AnalysisProviderRegistry {
return err(new AnalysisCancelledError("open_binary"));
if (
request === undefined &&
this.#byId.has(selector) &&
selector !== "auto" &&
baseline.length > 0 &&
baseline.every(
({ targetSupport }) => targetSupport.status === "unsupported",
@@ -1023,28 +1023,6 @@ it("serializes script loading against concurrent close and unloads the loaded sc
expect(bindings.device.session.isDetached()).toBe(true);
});
it("drains startup when the provider loader initiates shutdown", async () => {
const bindings = new FakeDeviceManager();
let closing: Promise<void> | undefined;
const manager = new FridaInstrumentationManager({
deviceManagerLoader: async () => {
closing = manager.closeAll();
return bindings;
},
});
const started = await manager.startSession({
mode: "spawn",
deviceId: "local",
program: "/tmp/fixture",
});
if (!started.ok) throw started.error;
expect(closing).toBeDefined();
await closing;
expect(bindings.device.session.isDetached()).toBe(true);
expect(bindings.device.resumed).toEqual([512]);
expect(manager.status(started.value.sessionId)).toBeUndefined();
});
it("names the unknown session or script ID it rejects", async () => {
const { manager, started } = await startLocalAttachedSession();
if (!started.ok) throw started.error;
@@ -1,8 +1,4 @@
import type {
AnalysisError,
AnalysisErrorOptions,
} from "../../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { OptionalProviderLoadFailure } from "../OptionalObservationProviders.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { Result } from "../../domain/result.js";
@@ -64,15 +60,6 @@ export interface FridaScriptObservation {
readonly messagesTruncated: boolean;
}
export class FridaScriptLoadCancelledError extends AnalysisCancelledError {
constructor(
readonly script: FridaScriptObservation,
options?: AnalysisErrorOptions,
) {
super("load_frida_script", options);
}
}
export interface FridaSessionStatus extends FridaSessionObservation {
/** True only after REA has successfully resumed this spawned target. */
readonly targetResumeObserved: boolean;
@@ -113,20 +100,14 @@ export interface FridaInstrumentationPort {
startSession(
input: StartFridaSessionInput,
): Promise<Result<FridaSessionObservation, AnalysisError>>;
/** Own cancellation and new-script cleanup before returning a final load outcome. */
loadScript(
sessionId: string,
source: FridaScriptSource,
signal?: AbortSignal,
): Promise<Result<FridaScriptObservation, AnalysisError>>;
resumeSession(
sessionId: string,
signal?: AbortSignal,
): Promise<Result<null, AnalysisError>>;
resumeSession(sessionId: string): Promise<Result<null, AnalysisError>>;
unloadScript(
sessionId: string,
scriptId: string,
signal?: AbortSignal,
): Promise<Result<null, AnalysisError>>;
status(sessionId: string): FridaSessionStatus | undefined;
closeSession(
@@ -3,7 +3,6 @@ import { join } from "node:path";
import { expect, it } from "vitest";
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
import type { FridaInstrumentationPort } from "./FridaInstrumentationPort.js";
import { FridaInstrumentationService } from "./FridaInstrumentationService.js";
import {
@@ -570,74 +569,3 @@ it("cleans up a session and returns typed cancellation after a pending start", a
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
expect(closed).toEqual(["00000000-0000-4000-8000-000000000001"]);
});
it("retains a cancelled provider load's source and messages in one-shot Evidence", async () => {
const controller = new AbortController();
const script = {
...createScriptObservation(),
messages: [{ type: "send", payload: "cancelled-load" }],
};
const service = new FridaInstrumentationService(
createProvider({
async loadScript() {
controller.abort(new Error("cancel during native load"));
return {
ok: false,
error: new FridaScriptLoadCancelledError(script, {
cause: controller.signal.reason,
}),
};
},
}),
);
const result = await service.instrument(
{
mode: "spawn",
program: "/fixture",
source: { sourceKind: "inline", source: "send('cancelled-load')" },
durationMs: 0,
},
controller.signal,
);
if (result.ok) throw new Error("Cancelled instrumentation succeeded");
expect(result.error.partialObservation).toMatchObject({
normalized_result: {
source_kind: "inline",
source_sha256: script.sourceSha256,
messages: script.messages,
instrumentation_cleanup: "released",
},
});
});
it("preserves a provider-committed load when cancellation arrives during result delivery", async () => {
const controller = new AbortController();
let unloads = 0;
const script = createScriptObservation();
const service = new FridaInstrumentationService(
createProvider({
loadScript() {
const completed = Promise.resolve({ ok: true as const, value: script });
void completed.then(() =>
controller.abort(new Error("late cancellation")),
);
return completed;
},
async unloadScript() {
unloads += 1;
return { ok: true, value: null };
},
}),
);
const loaded = await service.loadScript(
createSession().sessionId,
{ sourceKind: "inline", source: "send('ready')" },
controller.signal,
);
expect(controller.signal.aborted).toBe(true);
if (!loaded.ok) throw loaded.error;
expect(loaded.value.normalized_result).toMatchObject({
script_id: script.scriptId,
});
expect(unloads).toBe(0);
});
@@ -12,7 +12,6 @@ import {
AnalysisInputError,
} from "../../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../../domain/result.js";
import { FridaScriptLoadCancelledError } from "./FridaInstrumentationPort.js";
import type {
FridaDeviceSelector,
FridaInstrumentationPort,
@@ -182,17 +181,8 @@ export class FridaInstrumentationService {
async loadScript(
sessionId: string,
source: FridaScriptSource,
signal?: AbortSignal,
): Promise<Result<Evidence, AnalysisError>> {
if (signal?.aborted)
return err(
new AnalysisCancelledError("load_frida_script", {
cause: signal.reason,
}),
);
const loaded = await this.#provider.loadScript(sessionId, source, signal);
if (!loaded.ok && loaded.error instanceof FridaScriptLoadCancelledError)
return err(cancelledScriptEvidence(sessionId, loaded.error));
const loaded = await this.#provider.loadScript(sessionId, source);
return loaded.ok
? ok(
fridaEvidence("load_frida_script", scriptOutput(loaded.value), {
@@ -202,12 +192,12 @@ export class FridaInstrumentationService {
: loaded;
}
resumeSession(sessionId: string, signal?: AbortSignal) {
return this.#provider.resumeSession(sessionId, signal);
resumeSession(sessionId: string) {
return this.#provider.resumeSession(sessionId);
}
unloadScript(sessionId: string, scriptId: string, signal?: AbortSignal) {
return this.#provider.unloadScript(sessionId, scriptId, signal);
unloadScript(sessionId: string, scriptId: string) {
return this.#provider.unloadScript(sessionId, scriptId);
}
status(sessionId: string): FridaSessionStatus | undefined {
@@ -253,19 +243,14 @@ export class FridaInstrumentationService {
const loaded = await this.#provider.loadScript(
started.value.sessionId,
input.source,
signal,
);
let scriptObservation: FridaScriptObservation | undefined;
if (loaded.ok) scriptObservation = loaded.value;
else if (loaded.error instanceof FridaScriptLoadCancelledError)
scriptObservation = loaded.error.script;
if (signal?.aborted)
return this.#cancelSession(
"instrument_with_frida",
started.value,
input,
signal.reason,
scriptObservation,
loaded.ok ? loaded.value : undefined,
);
if (!loaded.ok) {
const closed = await this.#provider.closeSession(started.value.sessionId);
@@ -282,7 +267,6 @@ export class FridaInstrumentationService {
if (started.value.mode === "spawn") {
const resumed = await this.#provider.resumeSession(
started.value.sessionId,
signal,
);
if (signal?.aborted)
return this.#cancelSession(
@@ -478,23 +462,3 @@ const sessionStatusOutput = (status: FridaSessionStatus) => ({
messages: status.messages,
messages_truncated: status.messagesTruncated,
});
const cancelledScriptEvidence = (
sessionId: string,
error: FridaScriptLoadCancelledError,
): AnalysisCancelledError =>
new AnalysisCancelledError(error.operation, {
cause: error.cause,
cleanup: error.cleanup,
partialObservation: fridaEvidence(
error.operation,
{
...scriptOutput(error.script),
cleanup_error: error.cleanup?.reason ?? null,
script_cleanup: error.cleanupIncomplete
? "incomplete_or_unknown"
: "released",
},
{ session_id: sessionId },
),
});
@@ -92,11 +92,7 @@ export const compareManagedMemberPaths = async (
throw new EvidenceIntegrityError(
`Managed artifact digest changed after open: expected ${target.sha256}, observed ${snapshot.sha256} at ${target.path}`,
);
// Member comparison never reads literals, so it does not spend the
// decode budget on them.
const inspection = inspectManagedMembersBytes(snapshot.bytes, target, {
userStrings: false,
});
const inspection = inspectManagedMembersBytes(snapshot.bytes, target);
const evidence = createEvidence(target, MANAGED_STATIC_PROVIDER, {
operation: "inspect_managed_members",
parameters: {},
-19
View File
@@ -1,19 +0,0 @@
import type {
ExecutionOptions,
ProviderIdentity,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
InspectRustBinaryInput,
RustBinary,
} from "../../domain/rust/rustBinary.js";
import type { Result } from "../../domain/result.js";
/** Replaceable offline dependency reader with no target-execution authority. */
export interface RustBinaryPort {
readonly identity: ProviderIdentity;
inspect(
input: InspectRustBinaryInput,
options?: ExecutionOptions,
): Promise<Result<RustBinary, AnalysisError>>;
}
@@ -1,132 +0,0 @@
import { expect, it } from "vitest";
import {
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { RustBinary } from "../../domain/rust/rustBinary.js";
import { ok } from "../../domain/result.js";
import { RustBinaryService } from "./RustBinaryService.js";
const identity = {
id: "fixture-rust",
name: "Source-owned Rust reader seam",
version: "1",
};
const report: RustBinary = {
artifact: { path: "/artifacts/program", sha256: "a".repeat(64), bytes: 128 },
format: "elf",
architecture: "amd64",
bits: 64,
byte_order: "little",
auditable_metadata: {
section_name: ".dep-v0",
file_offset: 16,
section_bytes: 8,
compressed_bytes: 8,
uncompressed_bytes: 20,
format_version: 1,
packages: [
{
name: "sample",
version: "0.1.0",
source: "local",
kind: null,
dependencies: [],
root: true,
},
],
root_package_index: 0,
dependency_edges: [],
cyclic_dependency_indices: [],
},
rustc_commit: null,
limitations: [
"No /rustc/<40-hex>/ path was found in read-only data, so a rustc commit hash is unknown.",
],
};
it("rejects a relative path and extra arguments before reading", async () => {
let calls = 0;
const service = new RustBinaryService({
identity,
inspect: () => {
calls += 1;
return Promise.resolve(ok(report));
},
});
const relative = await service.inspect({ path: "program" });
const extra = await service.inspect({
path: report.artifact.path,
detail: "complete",
});
expect(relative.ok).toBe(false);
expect(extra.ok).toBe(false);
if (!relative.ok) expect(relative.error).toBeInstanceOf(AnalysisInputError);
if (!extra.ok) expect(extra.error).toBeInstanceOf(AnalysisInputError);
expect(calls).toBe(0);
});
it("records the selected artifact and the dependency section range", async () => {
const result = await new RustBinaryService({
identity,
inspect: () => Promise.resolve(ok(report)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(true);
if (!result.ok) throw result.error;
expect(result.value).toMatchObject({
confidence: "observed",
subject: { format: "elf", architecture: "x86_64" },
normalized_result: report,
locations: [
{ kind: "artifact-path", path: report.artifact.path },
{ kind: "file-offset-range", start: 16, end: 24 },
],
});
const mismatched: RustBinary = {
...report,
artifact: { ...report.artifact, path: "/artifacts/other" },
};
const invalid = await new RustBinaryService({
identity,
inspect: () => Promise.resolve(ok(mismatched)),
}).inspect({ path: report.artifact.path });
expect(invalid.ok).toBe(false);
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError);
});
it("rejects a cycle claim the package dependencies do not contain", async () => {
if (report.auditable_metadata === null) throw new Error("expected metadata");
const invalidReport: RustBinary = {
...report,
auditable_metadata: {
...report.auditable_metadata,
cyclic_dependency_indices: [0],
},
};
const result = await new RustBinaryService({
identity,
inspect: () => Promise.resolve(ok(invalidReport)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
});
it.each([
{ from_index: 1, to_index: 0 },
{ from_index: 0, to_index: 1 },
])("rejects provider edges referencing missing packages: %j", async (edge) => {
if (report.auditable_metadata === null) throw new Error("expected metadata");
const invalidReport: RustBinary = {
...report,
auditable_metadata: {
...report.auditable_metadata,
dependency_edges: [edge],
},
};
const result = await new RustBinaryService({
identity,
inspect: () => Promise.resolve(ok(invalidReport)),
}).inspect({ path: report.artifact.path });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError);
});
-113
View File
@@ -1,113 +0,0 @@
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { RustBinaryPort } from "./RustBinaryPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { BINARY_ARCHITECTURES } from "../../domain/binaryTargetTypes.js";
import {
inspectRustBinaryInputSchema,
rustBinarySchema,
} from "../../domain/rust/rustBinary.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
const OPERATION = "inspect_rust_binary";
const architectureForSubject = (architecture: string) => {
switch (architecture) {
case "386":
return "x86";
case "amd64":
return "x86_64";
default:
return BINARY_ARCHITECTURES.find(
(supported) => supported === architecture,
);
}
};
/** Shared exact input validation and artifact-bound Evidence for CLI and MCP. */
export class RustBinaryService {
constructor(readonly provider: RustBinaryPort) {}
/** Inspect one explicitly selected file without acquiring a binary session. */
async inspect(
rawInput: unknown,
options?: ExecutionOptions,
): Promise<Result<Evidence, AnalysisError>> {
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const input = inspectRustBinaryInputSchema.safeParse(rawInput);
if (!input.success)
return err(
analysisInputErrorFromIssues(OPERATION, input.error.issues, rawInput, {
cause: input.error,
}),
);
if (!isAbsolute(input.data.path))
return err(
new AnalysisInputError(OPERATION, undefined, [
{
path: ["path"],
reason: "invalid_format",
message: "Expected an absolute filesystem path on this host.",
},
]),
);
const inspected = await this.provider.inspect(input.data, options);
if (!inspected.ok) return inspected;
if (options?.signal?.aborted)
return err(new AnalysisCancelledError(OPERATION));
const report = rustBinarySchema.safeParse(inspected.value);
if (!report.success || report.data.artifact.path !== input.data.path)
return err(
new AnalysisOutputError(
OPERATION,
"Rust dependency reader returned malformed metadata or changed the selected artifact identity.",
),
);
return ok(evidenceFor(this.provider.identity, input.data, report.data));
}
}
const evidenceFor = (
provider: RustBinaryPort["identity"],
parameters: { readonly path: string },
value: ReturnType<typeof rustBinarySchema.parse>,
): Evidence => {
const architecture = architectureForSubject(value.architecture);
const section = value.auditable_metadata;
return createEvidence(
{
path: value.artifact.path,
sha256: value.artifact.sha256,
format: value.format === "macho" ? "mach-o" : value.format,
...(architecture === undefined ? {} : { architecture }),
},
provider,
{
operation: OPERATION,
parameters,
result: value,
confidence: "observed",
limitations: value.limitations,
locations: [
{ kind: "artifact-path", path: value.artifact.path },
...(section === null
? []
: [
{
kind: "file-offset-range" as const,
start: section.file_offset,
end: section.file_offset + section.section_bytes,
},
]),
],
},
);
};
+3 -1
View File
@@ -23,10 +23,11 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
);
};
/** Hash every stream byte and retain its bounded classification prefix. */
/** Hash every stream byte, optionally feeding the same bytes to a scan aggregate. */
export const hashReadable = async (
stream: Readable,
signal?: AbortSignal,
observeChunk?: (chunk: Buffer) => void,
): Promise<HashResult> => {
const hash = createHash("sha256");
const prefixes: Buffer[] = [];
@@ -37,6 +38,7 @@ export const hashReadable = async (
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
hash.update(chunk);
observeChunk?.(chunk);
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
const selected = chunk.subarray(
0,
+7 -1
View File
@@ -188,7 +188,10 @@ class ArtifactClient implements AnalysisClient {
const result = await extractArtifact(
{
inputPath: this.target.sourcePath ?? this.target.path,
inputFormat: this.target.format,
...(this.target.sourcePath === undefined ||
this.target.sourcePath === this.target.path
? { expectedRootSha256: this.target.sha256 }
: {}),
outputRoot: parsed.output_root,
environment: this.environment,
integrityPolicy: parsed.integrity_policy,
@@ -391,6 +394,9 @@ class ArtifactClient implements AnalysisClient {
environment: this.environment,
...(options?.signal === undefined ? {} : { signal: options.signal }),
integrity: { mode: parsed.integrity_policy },
...(this.target.kind === "archive" && this.target.format === "ines"
? { expectedRootSha256: this.target.sha256 }
: {}),
});
}
}
+3 -32
View File
@@ -1,8 +1,5 @@
import type { Readable } from "node:stream";
import type {
ArtifactCommand,
ArtifactInventoryCoverage,
} from "../domain/artifactGraph.js";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
import type {
AnalysisCleanupObservation,
@@ -93,36 +90,10 @@ export const sameArtifactEntry = (
entry.sourceIdentity?.mtimeNs === produced.sourceIdentity?.mtimeNs &&
entry.sourceIdentity?.ctimeNs === produced.sourceIdentity?.ctimeNs;
/** One entry or subtree deliberately excluded from a traversal. */
export interface ArtifactTraversalOmission {
readonly path: string;
readonly kind:
| "fifo"
| "socket"
| "block-device"
| "character-device"
| "unsupported"
| "directory-descendants";
}
/** Facts from an exhausted traversal, separate from entry content certainty. */
export interface ArtifactReaderTraversal {
readonly status: ArtifactInventoryCoverage["status"];
readonly limitations: readonly string[];
}
/** Per-iterator observation sink; adapters report intentional omissions without shared state. */
export interface ArtifactTraversalOptions {
readonly onOmission?: (omission: ArtifactTraversalOmission) => void;
}
/** Read-only adapter over one directory, archive, or virtual container. */
export interface ArtifactReader {
readonly format: "directory" | ZipPackageFormat | "asar" | "file";
entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry>;
readonly format: "directory" | ZipPackageFormat | "asar" | "ines" | "file";
entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry>;
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable>;
provenance(): readonly ArtifactCommand[];
close(): Promise<void>;
+6 -33
View File
@@ -14,18 +14,12 @@ import {
sameArtifactSourceIdentity,
type ArtifactEntry,
type ArtifactReader,
type ArtifactTraversalOmission,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { hostExecutableMarking } from "./hostExecutableMarking.js";
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
interface DirectoryTraversalOptions extends ArtifactTraversalOptions {
readonly descendInto?: (path: string) => boolean;
}
/** Non-symlink-following directory reader. */
export class DirectoryArtifactReader implements ArtifactReader {
readonly format = "directory" as const;
@@ -41,11 +35,9 @@ export class DirectoryArtifactReader implements ArtifactReader {
/** Enumerate entries, optionally restricting descent while retaining directories. */
async *entries(
signal?: AbortSignal,
selection?: DirectoryTraversalOptions | ((path: string) => boolean),
descendInto?: (path: string) => boolean,
): AsyncIterable<ArtifactEntry> {
const root = await this.#resolveRoot();
const options =
typeof selection === "function" ? { descendInto: selection } : selection;
const pending = [root];
while (pending.length > 0) {
abortIfNeeded(signal);
@@ -58,7 +50,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
directory,
pending,
signal,
options,
descendInto,
))
yield entry;
await withDirectoryIoContext("inspect directory", directory, () =>
@@ -83,7 +75,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
directory: string,
pending: string[],
signal?: AbortSignal,
options?: DirectoryTraversalOptions,
descendInto?: (path: string) => boolean,
): AsyncIterable<ArtifactEntry> {
const root = await this.#resolveRoot();
const handle = new OwnedDirectoryHandle(
@@ -111,16 +103,10 @@ export class DirectoryArtifactReader implements ArtifactReader {
);
const path = relative(root, absolute).split(sep).join("/");
const entry = artifactEntry(path, absolute, metadata);
if (entry === undefined) {
options?.onOmission?.({ path, kind: unsupportedEntryKind(metadata) });
continue;
}
if (entry === undefined) continue;
yield entry;
if (entry.kind === "directory") {
if (options?.descendInto?.(path) ?? true)
childDirectories.push(absolute);
else options?.onOmission?.({ path, kind: "directory-descendants" });
}
if (entry.kind === "directory" && (descendInto?.(path) ?? true))
childDirectories.push(absolute);
}
} catch (cause: unknown) {
traversal = {
@@ -270,19 +256,6 @@ export class DirectoryArtifactReader implements ArtifactReader {
}
}
const unsupportedEntryKind = (
metadata: BigIntStats,
): ArtifactTraversalOmission["kind"] =>
metadata.isFIFO()
? "fifo"
: metadata.isSocket()
? "socket"
: metadata.isBlockDevice()
? "block-device"
: metadata.isCharacterDevice()
? "character-device"
: "unsupported";
const artifactEntry = (
path: string,
absolute: string,
+187
View File
@@ -0,0 +1,187 @@
import { readFileHandlePrefix } from "../filesystem/readFileHandlePrefix.js";
import { Readable } from "node:stream";
import {
parseInesLayout,
type InesInventory,
type InesLayout,
} from "../domain/inesContainer.js";
import type { ArtifactCommand } from "../domain/artifactGraph.js";
import {
RegularFileChangedError,
verifyRegularFileState,
type StableRegularFileDescriptor,
} from "../filesystem/RegularFile.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
import { abortIfNeeded } from "./ArtifactHash.js";
import { streamChunkToBuffer } from "./StreamBytes.js";
export const INES_LIMITATION =
"Classic iNES file regions are observed; mapper execution, CPU bank mappings, CHR-RAM contents, 6502 analysis and emulator behavior are not observed. Trailing bytes are retained without interpretation. NES 2.0 and UNIF are unsupported.";
/** Actual concatenated bytes observed by the inventory's ordered region scan. */
export type InesRegionScan = Omit<InesInventory["reconstruction"], "verified">;
/** Read positional regions from a descriptor retained by the artifact resource scope. */
export class InesArtifactReader implements ArtifactReader {
readonly format = "ines" as const;
readonly #entries = new Map<string, ArtifactEntry>();
#layout: InesLayout | undefined;
#closed = false;
constructor(
private readonly path: string,
private readonly source: StableRegularFileDescriptor,
private readonly ownedSource?: OwnedFileHandle,
) {}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
abortIfNeeded(signal);
await this.#verifySource(signal);
const prefix = await readFileHandlePrefix(this.source.handle, 16, () =>
abortIfNeeded(signal),
);
abortIfNeeded(signal);
// Concurrent source changes take precedence over malformed header diagnostics.
await this.#verifySource(signal);
const parsed = parseInesLayout(prefix, this.source.initial.size);
if (!parsed.ok) throw new ArtifactReaderFailure("format", parsed.error);
this.#layout = parsed.value;
this.#entries.clear();
for (const region of parsed.value.regions) {
abortIfNeeded(signal);
const entry: ArtifactEntry = {
path: region.path,
kind: "file",
declaredSize: region.length,
compressedSize: null,
executable: false,
encrypted: false,
byteOffset: region.offset,
declaredSha256: null,
unpacked: false,
limitations: [],
adapterKey: region.path,
};
this.#entries.set(entry.adapterKey, copyArtifactEntry(entry));
yield entry;
}
}
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
abortIfNeeded(signal);
const produced = this.#entries.get(entry.adapterKey);
if (produced === undefined || !sameArtifactEntry(entry, produced))
throw new ArtifactReaderFailure(
"integrity",
"iNES region does not match this reader's inventory",
);
await this.#verifySource(signal);
const offset = produced.byteOffset;
const size = produced.declaredSize;
if (offset === null || size === null || size <= 0)
throw new ArtifactReaderFailure("integrity", "Invalid iNES region range");
const stream = readFileHandleChunks(this.source.handle, {
start: offset,
end: offset + size - 1,
signal,
});
const verify = () => this.#verifySource(signal);
return Readable.from(
(async function* () {
let bytes = 0;
try {
for await (const raw of stream) {
abortIfNeeded(signal);
const chunk = streamChunkToBuffer(raw);
bytes += chunk.length;
yield chunk;
}
if (bytes !== size)
throw new ArtifactReaderFailure(
"integrity",
"Truncated iNES region",
);
await verify();
} catch (cause: unknown) {
abortIfNeeded(signal);
if (cause instanceof ArtifactReaderFailure) throw cause;
throw new ArtifactReaderFailure("io", "Could not read iNES region", {
cause,
});
}
})(),
{ objectMode: false },
);
}
/** Validate the completed region scan without rereading its payload. */
async verifyReconstruction(
expectedSha256: string,
observed: InesRegionScan,
signal?: AbortSignal,
): Promise<InesInventory> {
abortIfNeeded(signal);
const layout = this.#layout;
if (layout === undefined || this.#entries.size !== layout.regions.length)
throw new ArtifactReaderFailure("integrity", "Incomplete iNES inventory");
await this.#verifySource(signal);
if (
observed.region_order.length !== layout.regions.length ||
layout.regions.some(
(region, index) => observed.region_order[index] !== region.path,
) ||
observed.bytes !== this.source.initial.size ||
observed.sha256 !== expectedSha256
)
throw new ArtifactReaderFailure(
"integrity",
"iNES reconstruction differs from the source",
);
return {
header: layout.header,
reconstruction: {
...observed,
verified: true,
},
};
}
provenance(): readonly ArtifactCommand[] {
return [];
}
close(): Promise<void> {
// Inventory borrows its admitting scope's descriptor. Extraction owns its
// separately opened descriptor and retains this reader if close fails.
this.#closed = true;
return this.ownedSource?.close() ?? Promise.resolve();
}
async #verifySource(signal?: AbortSignal): Promise<void> {
abortIfNeeded(signal);
if (this.#closed)
throw new ArtifactReaderFailure("unavailable", "iNES reader is closed");
try {
await verifyRegularFileState(this.path, this.source);
} catch (cause: unknown) {
abortIfNeeded(signal);
if (cause instanceof RegularFileChangedError)
throw new ArtifactReaderFailure(
"integrity",
"iNES source changed during reading",
{ cause: cause.cause ?? cause },
);
throw cause;
}
// State validation is asynchronous even after the final byte was consumed.
abortIfNeeded(signal);
}
}
+2 -14
View File
@@ -8,7 +8,6 @@ import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
type ArtifactTraversalOptions,
} from "./ArtifactReader.js";
import { DirectoryArtifactReader } from "./DirectoryArtifactReader.js";
import {
@@ -150,24 +149,13 @@ export class NativeDmgArtifactReader implements ArtifactReader {
this.host = host ?? createSystemHost(environment);
}
async *entries(
signal?: AbortSignal,
options?: ArtifactTraversalOptions,
): AsyncIterable<ArtifactEntry> {
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
await this.#ensureAttached(signal);
const directory = this.#directory;
if (directory === undefined)
throw new ArtifactReaderFailure("unavailable", "DMG is not attached");
const prefix = basename(this.path);
const entries = directory
.entries(signal, {
onOmission: (omission) =>
options?.onOmission?.({
...omission,
path: `${prefix}/${omission.path}`,
}),
})
[Symbol.asyncIterator]();
const entries = directory.entries(signal)[Symbol.asyncIterator]();
try {
for (;;) {
if (this.#closed) return;
+88 -25
View File
@@ -1,8 +1,13 @@
import { lstat, realpath } from "node:fs/promises";
import { realpath } from "node:fs/promises";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import { InesArtifactReader } from "../InesArtifactReader.js";
import {
openRootArtifact,
hashStableRootArtifactHandle,
} from "../inventory/hashStableRootArtifact.js";
import {
ArtifactPathRegistry,
normalizeArtifactPath,
@@ -32,10 +37,7 @@ import type {
ArtifactResourceOwner,
ArtifactResourceScope,
} from "../ArtifactResourceScope.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../../domain/binaryTargetTypes.js";
import { isMachOFormat } from "../../domain/binaryTargetTypes.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js";
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
@@ -43,7 +45,8 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
/** Local extraction input with the output root chosen by the adapter. */
export interface ArtifactExtractionInput {
readonly inputPath: string;
readonly inputFormat: BinaryTarget["format"];
/** Digest of the active session target, when extraction is session-bound. */
readonly expectedRootSha256?: string;
readonly outputRoot: string;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly integrityPolicy: ArtifactIntegrityPolicyName;
@@ -64,13 +67,22 @@ const extractArtifactInScope = async (
// Cancellation wins over every later refusal, including unsupported formats.
abortIfNeeded(signal);
const sourcePath = await realpath(input.inputPath);
await requireExtractableFormat(sourcePath, input);
const snapshot = await scanArtifactInventoryInScope(sourcePath, {
resourceScope: input.resourceScope,
signal,
environment: input.environment,
integrity: { mode: input.integrityPolicy },
admitRootFormat: (format) =>
requireExtractableFormat(format, input.inputPath),
});
if (
input.expectedRootSha256 !== undefined &&
snapshot.manifest.root_sha256 !== input.expectedRootSha256
)
throw new ArtifactReaderFailure(
"integrity",
`Active artifact digest changed: expected ${input.expectedRootSha256}, observed ${snapshot.manifest.root_sha256}`,
);
return materializeArtifactInventoryInScope(
input,
sourcePath,
@@ -217,7 +229,12 @@ const materializeSelection = async ({
const byPath = new Map(
selected.map((item) => [item.occurrence.logical_path, item]),
);
const reader = await createReader(sourcePath, input);
const reader = await createReader(
sourcePath,
input,
inventory.manifest,
signal,
);
const readerOwner: ArtifactResourceOwner = {
kind: "reader" as const,
reader,
@@ -402,43 +419,89 @@ interface ExtractionInventory {
}
/**
* Refuse a format without an extraction reader before inventory work starts.
* The target kind, not the host, is unsupported by extraction. The error keeps
* the caller's spelling of the path; I/O uses the canonical path.
* Admit the scanner's format, preserving the caller's path in a refusal.
*/
const requireExtractableFormat = async (
path: string,
input: Pick<ArtifactExtractionInput, "inputPath" | "inputFormat">,
): Promise<boolean> => {
const format = input.inputFormat;
const directory = (await lstat(path)).isDirectory();
const requireExtractableFormat = (
format: ArtifactGraphManifest["root_format"],
inputPath: string,
): void => {
if (
!directory &&
format !== "directory" &&
format !== "asar" &&
format !== "ines" &&
!isMachOFormat(format) &&
!isZipPackageFormat(format)
)
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
input.inputPath,
inputPath,
`Artifact format has no extraction reader: ${format}`,
);
return directory;
};
const createReader = async (
path: string,
input: Pick<
ArtifactExtractionInput,
"inputPath" | "inputFormat" | "environment"
"inputPath" | "environment" | "resourceScope"
>,
manifest: ArtifactGraphManifest,
signal?: AbortSignal,
): Promise<ArtifactReader> => {
const format = input.inputFormat;
if (await requireExtractableFormat(path, input))
return new DirectoryArtifactReader(path);
// The current scan owns format selection; the opened target may be stale.
const format = manifest.root_format;
if (format === "ines") {
const owner = await openRootArtifact(
path,
input.resourceScope,
signal,
"inventoried",
);
try {
const initial = await owner.handle.stat();
const digest = await hashStableRootArtifactHandle(
path,
owner.handle,
initial,
signal,
);
if (digest.sha256 !== manifest.root_sha256)
throw new ArtifactReaderFailure(
"integrity",
`Inventoried iNES source bytes changed: ${path}`,
);
return new InesArtifactReader(
path,
{
handle: owner.handle,
initial,
},
owner,
);
} catch (cause: unknown) {
const attempt = await input.resourceScope.release({
kind: "file-handle",
handle: owner,
resource: `iNES source ${path}`,
});
if (attempt.kind === "failed")
throw ArtifactReaderFailure.withCleanup(
cause,
ArtifactReaderFailure.cleanupObservation(attempt.cause, path),
);
throw cause;
}
}
if (format === "directory") return new DirectoryArtifactReader(path);
if (format === "asar") return new AsarArtifactReader(path);
if (isZipPackageFormat(format)) return new ZipArtifactReader(path, format);
return new MachOSliceArtifactReader(path, input.environment);
if (format === "mach-o" || format === "mach-o-universal")
return new MachOSliceArtifactReader(path, input.environment);
throw new AnalysisUnsupportedTargetError(
"extract_artifact",
input.inputPath,
`Artifact format has no extraction reader: ${format}`,
);
};
const preflight = (entry: ArtifactEntry): void => {
@@ -27,6 +27,10 @@ import {
classifyFatContainerIdentity,
readFatContainerHeader,
} from "../../domain/apple/machoContainer.js";
import {
nesContainerFormat,
parseInesLayout,
} from "../../domain/inesContainer.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
export interface MutableOccurrence {
@@ -227,7 +231,7 @@ export const rootOccurrenceFor = (
artifact_kind:
metadata.format === "directory" ||
isZipPackageFormat(metadata.format) ||
["asar", "dmg", "pkg"].includes(metadata.format)
["asar", "ines", "dmg", "pkg"].includes(metadata.format)
? "container"
: artifactRoleForFormat(metadata.path, metadata.format).kind,
artifact_format: metadata.format,
@@ -427,6 +431,7 @@ const artifactRoleForFormat = (
format: zipPackageFormatForPath(path) ?? format,
};
if (format === "plist") return { kind: "plist", format };
if (format === "ines") return { kind: "container", format };
return {
kind: ["native-addon", "dynamic-library"].includes(byPath.kind)
? byPath.kind
@@ -440,6 +445,13 @@ export const classifyArtifactBytes = (
prefix: Buffer,
fileSize = prefix.length,
): ArtifactNode["format"] => {
// A header-only region has the signature but not the declared cartridge
// extent. Keep it as a file instead of inventing another container.
if (
nesContainerFormat(prefix) === "ines" &&
parseInesLayout(prefix, fileSize).ok
)
return "ines";
if (hasZipSignature(prefix)) return "zip";
if (hasBinaryPlistSignature(prefix)) return "plist";
const fatFormat = classifyFatArtifact(prefix, fileSize);
+11 -1
View File
@@ -5,6 +5,7 @@ import {
type ArtifactInventoryOutput,
} from "../../domain/artifactGraph.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
import type { ArtifactInventoryOptions } from "./types.js";
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
@@ -33,5 +34,14 @@ export const scanArtifactInventoryInScope = async (
): Promise<ArtifactInventorySnapshot> => {
abortIfNeeded(options.signal);
const path = await realpath(inputPath);
return scanCanonicalArtifactInventoryInScope(path, options);
const snapshot = await scanCanonicalArtifactInventoryInScope(path, options);
if (
options.expectedRootSha256 !== undefined &&
snapshot.manifest.root_sha256 !== options.expectedRootSha256
)
throw new ArtifactReaderFailure(
"integrity",
`Active artifact digest changed: expected ${options.expectedRootSha256}, observed ${snapshot.manifest.root_sha256}`,
);
return snapshot;
};
+72 -12
View File
@@ -1,11 +1,23 @@
import { sameRegularFileState } from "../../filesystem/RegularFile.js";
import { readFileHandlePrefix } from "../../filesystem/readFileHandlePrefix.js";
import {
sameRegularFileState,
verifyRegularFileState,
RegularFileChangedError,
} from "../../filesystem/RegularFile.js";
import type { FileHandle } from "node:fs/promises";
import type { Stats } from "node:fs";
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js";
import {
nesContainerFormat,
parseInesLayout,
} from "../../domain/inesContainer.js";
import {
abortIfNeeded,
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
} from "../ArtifactHash.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
import {
hasZipSignature,
@@ -41,7 +53,10 @@ export const classifyAndHashRoot = async (
path: string,
directory: boolean,
expectedMetadata: Stats,
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
options: Pick<
ArtifactInventoryOptions,
"resourceScope" | "signal" | "admitRootFormat"
>,
): Promise<RootClassification> => {
return options.resourceScope.run(async () => {
const result = await classifyAndHashRootForInventory(
@@ -71,15 +86,21 @@ export const classifyAndHashRoot = async (
});
};
/** Classify a root and retain its admitted ZIP descriptor for child inventory. */
/** Classify a root and retain its admitted container descriptor for child inventory. */
export const classifyAndHashRootForInventory = async (
path: string,
directory: boolean,
expectedMetadata: Stats,
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
options: Pick<
ArtifactInventoryOptions,
"resourceScope" | "signal" | "admitRootFormat"
>,
): Promise<RootInventoryClassification> => {
if (directory)
abortIfNeeded(options.signal);
if (directory) {
options.admitRootFormat?.("directory");
return { format: "directory", digest: null, rootSource: undefined };
}
const ownedHandle = await openRootArtifact(
path,
options.resourceScope,
@@ -99,12 +120,20 @@ export const classifyAndHashRootForInventory = async (
| { readonly kind: "failed"; readonly cause: unknown };
try {
const initial = await handle.stat();
abortIfNeeded(options.signal);
if (!sameRegularFileState(expectedMetadata, initial))
throw new ArtifactReaderFailure(
"integrity",
`Root artifact changed before inventory: ${path}`,
);
const format = await classifyRootFormat(path, handle);
const format = await classifyRootFormat(
path,
handle,
initial,
options.signal,
);
abortIfNeeded(options.signal);
options.admitRootFormat?.(format);
const digest = await hashStableRootArtifactHandle(
path,
handle,
@@ -112,7 +141,9 @@ export const classifyAndHashRootForInventory = async (
options.signal,
);
const retainSource =
isZipPackageFormat(format) || format === "mach-o-universal";
isZipPackageFormat(format) ||
format === "mach-o-universal" ||
format === "ines";
outcome = {
kind: "completed",
value: {
@@ -124,7 +155,23 @@ export const classifyAndHashRootForInventory = async (
},
};
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
outcome = {
kind: "failed",
cause:
options.signal?.aborted === true
? new ArtifactReaderFailure(
"cancelled",
"Artifact inventory cancelled",
{ cause },
)
: cause instanceof RegularFileChangedError
? new ArtifactReaderFailure(
"integrity",
`Root artifact changed during inventory: ${path}`,
{ cause: cause.cause ?? cause },
)
: cause,
};
}
if (outcome.kind === "failed" || outcome.value.rootSource === undefined) {
const cleanupAttempt = await options.resourceScope.release(owner);
@@ -144,10 +191,23 @@ export const classifyAndHashRootForInventory = async (
const classifyRootFormat = async (
path: string,
handle: FileHandle,
initial: Stats,
signal?: AbortSignal,
): Promise<ArtifactOccurrence["artifact_format"]> => {
const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES);
const observed = await handle.read(magic, 0, magic.length, 0);
const prefix = magic.subarray(0, observed.bytesRead);
abortIfNeeded(signal);
const prefix = await readFileHandlePrefix(
handle,
ARTIFACT_CLASSIFICATION_PREFIX_BYTES,
() => abortIfNeeded(signal),
);
abortIfNeeded(signal);
await verifyRegularFileState(path, { handle, initial });
abortIfNeeded(signal);
if (nesContainerFormat(prefix) !== undefined) {
const parsed = parseInesLayout(prefix, initial.size);
if (!parsed.ok) throw new ArtifactReaderFailure("format", parsed.error);
return "ines";
}
const lower = path.toLowerCase();
if (hasZipSignature(prefix)) return zipRootPackageFormat(lower);
if (
@@ -1,4 +1,3 @@
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js";
@@ -9,7 +8,8 @@ import {
NonRegularFileReadError,
RegularFileAdmissionFailure,
openRegularFile,
sameRegularFileState,
RegularFileChangedError,
verifyRegularFileState,
} from "../../filesystem/RegularFile.js";
import {
abortIfNeeded,
@@ -23,6 +23,7 @@ export const openRootArtifact = async (
path: string,
resourceScope: ArtifactResourceScope,
signal?: AbortSignal,
admission: "initial" | "inventoried" = "initial",
): Promise<OwnedFileHandle> => {
try {
return new OwnedFileHandle(
@@ -31,14 +32,41 @@ export const openRootArtifact = async (
} catch (cause: unknown) {
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const changed =
admission === "inventoried" &&
(primary instanceof NonRegularFileReadError ||
primary instanceof RegularFileChangedError ||
(primary instanceof Error &&
"code" in primary &&
[
"ENOENT",
"ENOTDIR",
"ELOOP",
"EISDIR",
"ENOTFILE",
"ESTALE",
].includes(String(primary.code))));
// Normalize the primary failure before cleanup so failed closes keep both facts.
const failure =
primary instanceof NonRegularFileReadError
signal?.aborted === true
? new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
"cancelled",
"Artifact admission cancelled",
{ cause: primary },
)
: primary;
: changed
? new ArtifactReaderFailure(
"integrity",
`Inventoried artifact source changed: ${path}`,
{ cause: primary },
)
: primary instanceof NonRegularFileReadError
? new ArtifactReaderFailure(
"format",
`Artifact root is not a regular file: ${path}`,
{ cause: primary },
)
: primary;
if (cause instanceof RegularFileAdmissionFailure) {
const resource = `root artifact descriptor for ${path}`;
const cleanup = await resourceScope.release({
@@ -105,12 +133,20 @@ export const hashStableRootArtifactHandle = async (
signal,
);
abortIfNeeded(signal);
const [opened, currentPath] = await Promise.all([handle.stat(), lstat(path)]);
if (
!sameRegularFileState(initial, opened) ||
!sameRegularFileState(initial, currentPath) ||
digest.bytes !== initial.size
)
try {
await verifyRegularFileState(path, { handle, initial });
} catch (cause: unknown) {
abortIfNeeded(signal);
if (cause instanceof RegularFileChangedError)
throw new ArtifactReaderFailure(
"integrity",
`Root artifact changed during inventory: ${path}`,
{ cause: cause.cause ?? cause },
);
throw cause;
}
abortIfNeeded(signal);
if (digest.bytes !== initial.size)
throw new ArtifactReaderFailure(
"integrity",
`Root artifact changed during inventory: ${path}`,
+45 -42
View File
@@ -1,8 +1,7 @@
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import type {
ArtifactReader,
ArtifactReaderTraversal,
} from "../ArtifactReader.js";
import { InesArtifactReader, INES_LIMITATION } from "../InesArtifactReader.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import type { ArtifactReader } from "../ArtifactReader.js";
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
@@ -10,6 +9,7 @@ import { ZipArtifactReader } from "../ZipArtifactReader.js";
import type {
ArtifactInventoryCoverage,
ArtifactOccurrence,
IntegrityContradiction,
} from "../../domain/artifactGraph.js";
import {
isZipPackageFormat,
@@ -26,6 +26,13 @@ export const createReader = (
if (isZipPackageFormat(format))
return new ZipArtifactReader(path, format, undefined, rootSource);
switch (format) {
case "ines":
if (rootSource === undefined)
throw new ArtifactReaderFailure(
"integrity",
"iNES inventory requires its admitted source descriptor",
);
return new InesArtifactReader(path, rootSource);
case "directory":
return new DirectoryArtifactReader(path);
case "asar":
@@ -42,47 +49,43 @@ export const createReader = (
}
};
/** Decide traversal scope and availability once, independently of diagnostic wording. */
export const inventoryCoverage = (
/** Classify source coverage and preserve its user-facing explanation once. */
export const inventoryAssessment = (
format: ArtifactOccurrence["artifact_format"],
reader: ArtifactReader | undefined,
traversal: ArtifactReaderTraversal,
integrityContradictions: readonly IntegrityContradiction[],
caseCollisionLimitation: string | undefined,
): {
readonly coverage: ArtifactInventoryCoverage;
readonly coverage_status: ArtifactInventoryCoverage;
readonly limitations: string[];
} => {
if (reader !== undefined)
return {
coverage: { scope: "container-entries", status: traversal.status },
limitations: [
...(isZipPackageFormat(reader.format)
? [ZIP_NON_ENTRY_TAIL_LIMITATION]
: []),
...traversal.limitations,
],
};
// dmg and pkg reach here only after classifyRootFormat matched their bytes.
if (format === "dmg" || format === "pkg")
return {
coverage: { scope: "container-entries", status: "unknown" },
limitations: [
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
],
};
if (format === "mach-o-universal")
return {
coverage: { scope: "container-entries", status: "unknown" },
limitations: [
"Universal Mach-O slices require the native macOS lipo adapter.",
],
};
if (isZipPackageFormat(format) || format === "directory" || format === "asar")
return {
coverage: { scope: "container-entries", status: "unknown" },
limitations: [`${format} child inventory reader is unavailable.`],
};
return {
coverage: { scope: "root", status: "complete" },
limitations: ["Artifact has no child container entries."],
};
let coverageStatus: ArtifactInventoryCoverage = "complete";
const limitations: string[] = [];
if (format === "ines") limitations.push(INES_LIMITATION);
if (reader !== undefined) {
if (isZipPackageFormat(reader.format))
limitations.push(ZIP_NON_ENTRY_TAIL_LIMITATION);
} else if (format === "dmg" || format === "pkg") {
coverageStatus = "partial";
limitations.push(
`${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`,
);
} else if (format === "mach-o-universal" && process.platform !== "darwin") {
coverageStatus = "partial";
limitations.push(
"Universal Mach-O slices require the native macOS lipo adapter.",
);
} else {
// The graph's modeled scope is the root itself for recognized leaf formats.
limitations.push("Artifact has no child container entries.");
}
if (caseCollisionLimitation !== undefined) {
coverageStatus = "partial";
limitations.push(caseCollisionLimitation);
}
if (integrityContradictions.length > 0)
limitations.push(
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
);
return { coverage_status: coverageStatus, limitations };
};
+31 -28
View File
@@ -1,7 +1,12 @@
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
import type { InesInventory } from "../../domain/inesContainer.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import {
InesArtifactReader,
type InesRegionScan,
} from "../InesArtifactReader.js";
import { lstat } from "node:fs/promises";
import type { Stats } from "node:fs";
@@ -10,7 +15,6 @@ import type { RootInventorySource } from "./classify.js";
import {
ArtifactReaderFailure,
type ArtifactReader,
type ArtifactReaderTraversal,
} from "../ArtifactReader.js";
import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js";
import {
@@ -41,7 +45,7 @@ import {
hashStableRootArtifact,
hashStableRootArtifactHandle,
} from "./hashStableRootArtifact.js";
import { createReader, inventoryCoverage } from "./reader.js";
import { createReader, inventoryAssessment } from "./reader.js";
import {
scanReader,
type PendingIntegrityContradiction,
@@ -100,7 +104,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
occurrences,
pendingContradictions,
caseCollisionLimitation,
traversal,
inesRegionScan,
} = await scanReader(reader, ownedReaders, options.signal, integrity);
outcome = {
kind: "completed",
@@ -117,7 +121,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
occurrences,
pendingContradictions,
caseCollisionLimitation,
traversal,
inesRegionScan,
}),
};
} catch (cause: unknown) {
@@ -199,7 +203,7 @@ interface SnapshotBuildInput {
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
readonly traversal: ArtifactReaderTraversal;
readonly inesRegionScan: InesRegionScan | undefined;
}
const buildInventorySnapshot = async (
@@ -272,22 +276,35 @@ const buildInventorySnapshot = async (
orderedOccurrences,
orderedEdges,
});
const traversal = inventoryCoverage(rootFormat, reader, input.traversal);
const assessment = inventoryAssessment(
rootFormat,
reader,
integrityContradictions,
input.caseCollisionLimitation,
);
let ines: InesInventory | undefined;
if (reader instanceof InesArtifactReader) {
if (rootDigest === null || input.inesRegionScan === undefined)
throw new ArtifactReaderFailure(
"integrity",
"Missing iNES reconstruction scan",
);
ines = await reader.verifyReconstruction(
rootDigest.sha256,
input.inesRegionScan,
signal,
);
}
return {
manifest,
...(ines === undefined ? {} : { ines }),
nodes: orderedNodes,
occurrences: orderedOccurrences,
edges: orderedEdges,
provenance: reader?.provenance() ?? [],
integrity_contradictions: integrityContradictions,
coverage: traversal.coverage,
coverage_status:
traversal.coverage.status === "complete" ? "complete" : "partial",
limitations: buildLimitations(
traversal.limitations,
integrityContradictions,
input.caseCollisionLimitation,
),
coverage_status: assessment.coverage_status,
limitations: assessment.limitations,
};
};
@@ -400,17 +417,3 @@ const buildManifest = ({
occurrence_count: orderedOccurrences.length,
edge_count: orderedEdges.length,
});
const buildLimitations = (
scopeLimitations: readonly string[],
integrityContradictions: readonly IntegrityContradiction[],
caseCollisionLimitation: string | undefined,
): string[] => [
...scopeLimitations,
...(caseCollisionLimitation === undefined ? [] : [caseCollisionLimitation]),
...(integrityContradictions.length === 0
? []
: [
`${String(integrityContradictions.length)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`,
]),
];
+40 -37
View File
@@ -1,4 +1,5 @@
import { isAbsolute } from "node:path";
import { createHash, type Hash } from "node:crypto";
import {
annotateEnglishUnicodeCaseCollisions,
@@ -9,12 +10,13 @@ import {
ArtifactReaderFailure,
type ArtifactEntry,
type ArtifactReader,
type ArtifactReaderTraversal,
type ArtifactTraversalOmission,
} from "../ArtifactReader.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import {
InesArtifactReader,
type InesRegionScan,
} from "../InesArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
import {
classifyArtifactContent,
classifyArtifactBytes,
@@ -58,7 +60,13 @@ interface ScanContext {
readonly registry: ArtifactPathRegistry;
readonly expandedContainerIds: Set<string>;
readonly ownedReaders: ArtifactReader[];
readonly omissions: ArtifactTraversalOmission[];
readonly reconstruction:
| {
readonly hash: Hash;
readonly regionOrder: string[];
bytes: number;
}
| undefined;
}
export const scanReader = async (
@@ -71,7 +79,7 @@ export const scanReader = async (
readonly occurrences: MutableOccurrence[];
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly caseCollisionLimitation: string | undefined;
readonly traversal: ArtifactReaderTraversal;
readonly inesRegionScan: InesRegionScan | undefined;
}> => {
const { nodes, occurrences, pendingContradictions } = emptyScan();
if (reader === undefined)
@@ -80,7 +88,7 @@ export const scanReader = async (
occurrences,
pendingContradictions,
caseCollisionLimitation: undefined,
traversal: { status: "complete", limitations: [] },
inesRegionScan: undefined,
};
const context: ScanContext = {
reader,
@@ -93,7 +101,10 @@ export const scanReader = async (
registry: new ArtifactPathRegistry(),
expandedContainerIds: new Set<string>(),
ownedReaders,
omissions: [],
reconstruction:
reader instanceof InesArtifactReader
? { hash: createHash("sha256"), regionOrder: [], bytes: 0 }
: undefined,
};
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
@@ -108,37 +119,17 @@ export const scanReader = async (
occurrences,
pendingContradictions,
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
traversal: {
status: context.omissions.length === 0 ? "complete" : "unknown",
limitations: [
...new Set(
context.omissions.map(
({ path, kind }) =>
`Artifact traversal omitted ${kind} at ${JSON.stringify(path)}.`,
),
),
].sort(compareUnicodeCodePoints),
},
inesRegionScan:
context.reconstruction === undefined
? undefined
: {
region_order: context.reconstruction.regionOrder,
bytes: context.reconstruction.bytes,
sha256: context.reconstruction.hash.digest("hex"),
},
};
};
const inventoryEntries = (
context: ScanContext,
reader: ArtifactReader,
prefix: string,
): AsyncIterator<ArtifactEntry> =>
reader
.entries(context.signal, {
onOmission: (omission) =>
context.omissions.push({
...omission,
path: normalizeArtifactPath(
prefix.length === 0 ? omission.path : `${prefix}/${omission.path}`,
),
}),
})
[Symbol.asyncIterator]();
const visitArtifactEntries = async (
context: ScanContext,
currentReader: ArtifactReader,
@@ -152,7 +143,7 @@ const visitArtifactEntries = async (
{
reader: currentReader,
prefix,
iterator: inventoryEntries(context, currentReader, prefix),
iterator: currentReader.entries(context.signal)[Symbol.asyncIterator](),
},
];
let failure: { readonly cause: unknown } | undefined;
@@ -226,7 +217,7 @@ const visitArtifactEntries = async (
stack.push({
reader: nested,
prefix: logicalPath,
iterator: inventoryEntries(context, nested, logicalPath),
iterator: nested.entries(context.signal)[Symbol.asyncIterator](),
});
}
}
@@ -268,10 +259,22 @@ const digestArtifactEntry = async (
> => {
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
return undefined;
const reconstruction =
currentReader === context.reader ? context.reconstruction : undefined;
if (reconstruction !== undefined && entry.byteOffset !== reconstruction.bytes)
throw new ArtifactReaderFailure("integrity", "Noncontiguous iNES regions");
const digest = await hashReadable(
await currentReader.open(entry, context.signal),
context.signal,
reconstruction === undefined
? undefined
: (chunk) => {
// Hash the same bytes used for each node, in physical region order.
reconstruction.hash.update(chunk);
reconstruction.bytes += chunk.length;
},
);
reconstruction?.regionOrder.push(entry.path);
const mismatched =
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
if (mismatched && entry.declaredSha256 !== null) {
+7
View File
@@ -1,4 +1,5 @@
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
/** Resolved integrity behavior admitted to the artifact scanner. */
export type ArtifactIntegrityPolicy =
@@ -12,7 +13,13 @@ export const STRICT_INTEGRITY_POLICY: ArtifactIntegrityPolicy = {
/** Options shared by artifact inventory scans. */
export interface ArtifactInventoryOptions {
readonly resourceScope: ArtifactResourceScope;
/** Bind a session-owned leaf inventory to the admitted root digest. */
readonly expectedRootSha256?: string | undefined;
readonly signal?: AbortSignal | undefined;
readonly integrity?: ArtifactIntegrityPolicy | undefined;
readonly environment?: Readonly<NodeJS.ProcessEnv> | undefined;
/** Admit the current root before payload hashing or native child inventory. */
readonly admitRootFormat?: (
format: ArtifactOccurrence["artifact_format"],
) => void;
}
+3 -25
View File
@@ -1,6 +1,5 @@
import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type { BrowserExclusionReason } from "../domain/browserCompleteness.js";
import { CdpConnection } from "./CdpConnection.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import {
@@ -27,7 +26,6 @@ export const captureStorage = async (
limitations: string[],
): Promise<{
readonly value: WebPageInspection["storage"];
readonly keysExclusion: BrowserExclusionReason | undefined;
}> => {
const origin = new URL(pageUrl).origin;
const quota = recordValue(
@@ -84,18 +82,7 @@ export const captureStorage = async (
// list is an incomplete enumeration rather than an empty one.
Array.isArray(cacheField) &&
caches.length === cacheField.length;
const keysExclusion = !context.input.include_storage_keys
? undefined
: local.exclusion === "provider_unavailable" ||
session.exclusion === "provider_unavailable" ||
indexedRaw === undefined ||
cacheRaw === undefined
? "provider_unavailable"
: !local.complete || !session.complete || !structuredStorageComplete
? "invalid_protocol_value"
: undefined;
return {
keysExclusion,
value: {
origin,
usage_bytes: numberValue(quota?.usage) ?? null,
@@ -112,11 +99,7 @@ export const captureStorage = async (
};
};
type StorageItemsCapture = CapturedStorageItems & {
readonly exclusion?: "provider_unavailable" | "invalid_protocol_value";
};
const emptyStorageItems: StorageItemsCapture = {
const emptyStorageItems: CapturedStorageItems = {
items: [],
complete: false,
};
@@ -135,7 +118,7 @@ const storageItems = async (
origin: string,
isLocalStorage: boolean,
limitations: string[],
): Promise<StorageItemsCapture> => {
): Promise<CapturedStorageItems> => {
const raw = await optionalCdpCommand(
context,
"DOMStorage.getDOMStorageItems",
@@ -144,12 +127,7 @@ const storageItems = async (
);
const entries = recordValue(raw)?.entries;
if (raw === undefined || !Array.isArray(entries))
return {
items: [],
complete: false,
exclusion:
raw === undefined ? "provider_unavailable" : "invalid_protocol_value",
};
return { items: [], complete: false };
const items = entries.flatMap((entry) => {
if (!Array.isArray(entry)) return [];
const key = cdpStringValue(entry[0]);
+11 -66
View File
@@ -193,9 +193,7 @@ const addIndexedDb = async (
scope: "indexed_db_schema",
identity: databaseName,
value: stores.map(indexedDbStoreSchema),
complete:
stores.length === database.objectStores.length &&
indexedDbSchemaComplete(stores),
complete: indexedDbSchemaComplete(stores),
});
for (const store of stores) {
const objectStoreName = cdpStringValue(store.name);
@@ -230,37 +228,19 @@ const indexedDbSchemaComplete = (
stores.every(
(store) =>
cdpStringValue(store.name) !== undefined &&
indexedDbKeyPathComplete(store.keyPath) &&
stableValueComplete(store.keyPath) &&
typeof store.autoIncrement === "boolean" &&
Array.isArray(store.indexes) &&
recordsValue(store.indexes).length === store.indexes.length &&
recordsValue(store.indexes).every(
(index) =>
cdpStringValue(index.name) !== undefined &&
indexedDbKeyPathComplete(index.keyPath) &&
stableValueComplete(index.keyPath) &&
typeof index.unique === "boolean" &&
typeof index.multiEntry === "boolean",
),
);
const indexedDbKeyPathComplete = (value: unknown): boolean => {
const keyPath = recordValue(value);
if (keyPath === undefined) return false;
switch (keyPath.type) {
case "null":
return true;
case "string":
return typeof keyPath.string === "string";
case "array":
return (
Array.isArray(keyPath.array) &&
keyPath.array.every((key) => typeof key === "string")
);
default:
return false;
}
};
const addIndexedDbRecords = async (
run: FingerprintRun,
input: {
@@ -494,49 +474,14 @@ const stableValueComplete = (value: unknown): boolean => {
const remoteObjectComplete = (value: unknown): boolean => {
const record = recordValue(value);
if (
record === undefined ||
record.objectId !== undefined ||
record.preview !== undefined ||
record.customPreview !== undefined ||
!stableValueComplete(record)
)
return false;
switch (record.type) {
case "undefined":
return (
record.value === undefined && record.unserializableValue === undefined
);
case "string":
case "boolean":
return (
typeof record.value === record.type &&
record.unserializableValue === undefined
);
case "number":
return record.unserializableValue === undefined
? typeof record.value === "number" && Number.isFinite(record.value)
: record.value === undefined &&
typeof record.unserializableValue === "string" &&
["-0", "NaN", "Infinity", "-Infinity"].includes(
record.unserializableValue,
);
case "bigint":
return (
record.value === undefined &&
typeof record.unserializableValue === "string" &&
/^-?\d+n$/u.test(record.unserializableValue)
);
case "object":
return (
record.unserializableValue === undefined &&
(record.subtype === "null"
? record.value === null
: record.value !== null && typeof record.value === "object")
);
default:
return false;
}
if (record === undefined) return true;
return (
record.objectId === undefined &&
record.preview === undefined &&
record.customPreview === undefined &&
(record.type !== "object" || record.value !== undefined) &&
stableValueComplete(record)
);
};
const decodeBase64 = (value: string): Buffer | null => {
-6
View File
@@ -155,12 +155,6 @@ const captureAuthorizedPage = async (
);
if (!input.include_storage_keys)
state.events.completeness.exclude("storage_keys", "not_approved", null);
else if (storageCapture.keysExclusion !== undefined)
state.events.completeness.exclude(
"storage_keys",
storageCapture.keysExclusion,
null,
);
await report(context.progress, 3, "Normalizing browser evidence");
const completedFrameResult = await authorizedMainFrame({
connection: context.connection,
@@ -2,6 +2,17 @@ import {
AnalysisCancelledError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js";
export const PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS = 5_000;
// Cleanup drains bounded content reads before closing Chrome and removing its
// profile. Cover those stages and retain the observed 30-second allowance for
// slow persistent-context close on shared runners.
export const PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS = Math.max(
30_000,
PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS + WEB_RUNTIME_LIMITS.cleanupTimeoutMs,
);
const OPERATION = "capture_browser_scenario" as const;
+33 -1
View File
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
import type { Browser } from "playwright-core";
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS } from "./PlaywrightExecutionBoundary.js";
import {
attachedScenarioLeaseKey,
openPlaywrightScenarioBrowser,
@@ -144,6 +145,35 @@ it("keeps attached-target ownership until cancelled cleanup actually settles", a
expect(settled).toEqual([true]);
});
it("waits for bounded event finalization and owned browser teardown", async () => {
vi.useFakeTimers();
try {
const closeBrowser = vi.fn(
() => new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
);
const removeProfile = vi.fn(async () => undefined);
const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
removeProfile,
});
const finished = cleanup
.close(() => new Promise<void>((resolve) => setTimeout(resolve, 4_500)))
.then(
() => ({ ok: true }),
(cause: unknown) => ({ ok: false, cause }),
);
await vi.advanceTimersByTimeAsync(4_500);
expect(removeProfile).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_500);
expect(await finished).toEqual({ ok: true });
expect(cleanup.browserClosed).toBe(true);
expect(closeBrowser).toHaveBeenCalledOnce();
expect(removeProfile).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("keeps attached-target ownership after the cleanup response timeout", async () => {
vi.useFakeTimers();
try {
@@ -170,7 +200,9 @@ it("keeps attached-target ownership after the cleanup response timeout", async (
responseSettled = true;
})
.catch(() => {});
await vi.advanceTimersByTimeAsync(29_999);
await vi.advanceTimersByTimeAsync(
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS - 1,
);
expect(responseSettled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await timeoutExpectation;
+5 -6
View File
@@ -8,13 +8,12 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { isLiteralLoopbackHostname } from "../domain/browserObservation.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
import {
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
withPlaywrightExecutionBoundary,
} from "./PlaywrightExecutionBoundary.js";
const OPERATION = "capture_browser_scenario" as const;
// Closing a real Chrome persistent context can take several seconds even when
// it succeeds. The shorter operation deadline must not misreport cleanup.
const CLEANUP_CONFIRMATION_TIMEOUT_MS = 30_000;
const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => {
if (signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
@@ -76,7 +75,7 @@ export class PlaywrightScenarioBrowserCleanupOwner {
try {
await withPlaywrightExecutionBoundary(
() => cleanup,
CLEANUP_CONFIRMATION_TIMEOUT_MS,
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
signal,
);
} catch (cause: unknown) {
+81 -15
View File
@@ -14,19 +14,9 @@ const mocks = vi.hoisted(() => ({
finishEvents: vi.fn(),
}));
vi.mock("./PlaywrightScenarioBrowser.js", () => ({
vi.mock("./PlaywrightScenarioBrowser.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./PlaywrightScenarioBrowser.js")>()),
openPlaywrightScenarioBrowser: mocks.openBrowser,
failBrowserScenarioOperation: async (
cleanup: () => Promise<void>,
cause: unknown,
) => {
try {
await cleanup();
} catch (cleanupCause: unknown) {
throw new AggregateError([cause, cleanupCause]);
}
throw cause;
},
}));
vi.mock("./PlaywrightScenarioStorage.js", () => ({
@@ -63,6 +53,7 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({
import { browserScenarioSchema } from "../domain/browserScenario.js";
import { PlaywrightScenarioSession } from "./PlaywrightScenarioSession.js";
import { PlaywrightScenarioBrowserCleanupOwner } from "./PlaywrightScenarioBrowser.js";
const scenario = browserScenarioSchema.parse({
browser: { mode: "launch", executable_path: process.execPath },
@@ -106,25 +97,100 @@ const fixture = () => {
return browserClosed;
},
};
mocks.openBrowser.mockResolvedValue({
const opened = {
page,
context,
browser,
cleanup,
profilePath: "/tmp/profile",
});
};
mocks.openBrowser.mockResolvedValue(opened);
mocks.storage = {
initialize: vi.fn(async () => undefined),
settle: vi.fn(async () => undefined),
close: vi.fn(async () => undefined),
};
return { cleanup, page, storage: mocks.storage };
return { cleanup, page, context, storage: mocks.storage, opened };
};
beforeEach(() => {
vi.clearAllMocks();
});
it("finishes initialization cleanup independently of request cancellation", async () => {
const { context, opened } = fixture();
const controller = new AbortController();
const closeBrowser = vi.fn(async () => undefined);
const removeProfile = vi.fn(async () => undefined);
mocks.openBrowser.mockResolvedValue({
...opened,
cleanup: new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
removeProfile,
}),
});
context.addCookies.mockImplementationOnce(async () => {
controller.abort();
});
const retainCleanup = vi.fn();
await expect(
PlaywrightScenarioSession.open(
scenario,
{},
{
signal: controller.signal,
retainCleanup,
},
),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
expect(closeBrowser).toHaveBeenCalledTimes(1);
expect(removeProfile).toHaveBeenCalledTimes(1);
expect(retainCleanup).not.toHaveBeenCalled();
});
it("can retry cleanup of a browser that opened after request cancellation", async () => {
const { opened } = fixture();
const closeBrowser = vi
.fn()
.mockRejectedValueOnce(new Error("transport close failed"))
.mockResolvedValueOnce(undefined);
const removeProfile = vi.fn(async () => undefined);
const lateOpened = {
...opened,
cleanup: new PlaywrightScenarioBrowserCleanupOwner({
closeBrowser,
removeProfile,
}),
};
let resolveOpening: ((value: typeof lateOpened) => void) | undefined;
const opening = new Promise<typeof lateOpened>((resolve) => {
resolveOpening = resolve;
});
mocks.openBrowser.mockReturnValueOnce(opening);
const controller = new AbortController();
const retained: (() => Promise<unknown>)[] = [];
const result = PlaywrightScenarioSession.open(
scenario,
{},
{
signal: controller.signal,
retainCleanup: (close) => retained.push(close),
},
);
const cancelled = expect(result).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
});
controller.abort();
await cancelled;
resolveOpening?.(lateOpened);
await vi.waitFor(() => expect(closeBrowser).toHaveBeenCalledTimes(1));
expect(retained).toHaveLength(1);
await expect(retained[0]?.()).resolves.toBeUndefined();
expect(closeBrowser).toHaveBeenCalledTimes(2);
expect(removeProfile).toHaveBeenCalledTimes(1);
});
it("uses browser disconnect as terminal storage release after detach failure", async () => {
const { cleanup, storage } = fixture();
const detachFailure = new Error("CDP detach rejected");
+10 -4
View File
@@ -14,7 +14,10 @@ import { performPlaywrightScenarioAction } from "./PlaywrightScenarioActions.js"
import { capturePlaywrightStepArtifacts } from "./PlaywrightScenarioArtifacts.js";
import { PlaywrightScenarioEvents } from "./PlaywrightScenarioEvents.js";
import { PlaywrightScenarioStorage } from "./PlaywrightScenarioStorage.js";
import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.js";
import {
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
withPlaywrightExecutionBoundary,
} from "./PlaywrightExecutionBoundary.js";
import {
browserScenarioCaptureData,
browserScenarioOperationFailure,
@@ -148,13 +151,17 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
options.signal,
);
} catch (cause: unknown) {
// Resource release outlives the cancelled request, including retained retries.
const lateCleanup = () =>
opening.then(
(lateOpened) => lateOpened.cleanup.close(undefined, options.signal),
(lateOpened) => lateOpened.cleanup.close(),
() => undefined,
);
options.retainCleanup?.(() =>
withPlaywrightExecutionBoundary(lateCleanup, 1_000),
withPlaywrightExecutionBoundary(
lateCleanup,
PLAYWRIGHT_SCENARIO_CLEANUP_TIMEOUT_MS,
),
);
void lateCleanup().catch((cleanupCause: unknown) => {
// best-effort cleanup: retainCleanup keeps the retry capability.
@@ -280,7 +287,6 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
events === undefined
? undefined
: () => events?.finish() ?? Promise.resolve(),
options.signal,
);
} catch (failure: unknown) {
browserFailure = { cause: failure };
-15
View File
@@ -186,21 +186,6 @@ it("excludes structural auth material and explicit normalized values without gue
expect(traces[0]?.incomplete_fact_records).toContain(2);
});
it("omits a hidden derived total for a stream with more contributions than call arguments allow", () => {
const events = Array.from({ length: 130 }, (_, index) =>
event(
Array.from({ length: 1_000 }, (_, offset) =>
frame(index * 1_000 + offset, 1),
),
),
);
const { traces } = facts(capture(events), ["130000"]);
expect(traces[0]?.streams).toEqual([]);
expect(traces[0]?.incomplete_fact_records).toEqual(
Array.from({ length: 130 }, (_, ordinal) => ordinal),
);
});
it("hides marked ancestor keys and safe derived sums while still validating hidden subtrees", () => {
const text = capture([event([frame(0, 3), frame(3, 4)])]);
expect(facts(text, ["7"]).traces[0]?.streams).toEqual([]);
+3 -3
View File
@@ -95,10 +95,10 @@ export const decodeQlogCapture = (
...stream.reported_final_sizes,
...stream.covered_ranges.flatMap((r) => [r.start, r.end]),
];
// A stream can have more contributions than call arguments allow.
if (numericValues.some(excluded))
for (const contribution of stream.contributions)
fact.incomplete_fact_records.push(contribution.record_ordinal);
fact.incomplete_fact_records.push(
...stream.contributions.map((c) => c.record_ordinal),
);
else fact.streams.push(stream);
}
fact.incomplete_fact_records = [...new Set(fact.incomplete_fact_records)];
@@ -4,6 +4,7 @@ import type {
} from "../../domain/browserNetworkEvidence.js";
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
import { PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS } from "../PlaywrightExecutionBoundary.js";
import {
captureNetworkBody,
captureNetworkHeaders,
@@ -76,7 +77,7 @@ export class PlaywrightScenarioNetwork {
constructor(private readonly options: NetworkCaptureOptions) {
this.readOptions = {
timeoutMs: options.readTimeoutMs ?? 5_000,
timeoutMs: options.readTimeoutMs ?? PLAYWRIGHT_NETWORK_READ_TIMEOUT_MS,
...(options.signal === undefined ? {} : { signal: options.signal }),
};
}
-2
View File
@@ -5,7 +5,6 @@ import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
import { createDirectAnalysis } from "./composition/directAnalysis.js";
import { registerEvmCommands } from "./cli/evmCommands.js";
import { registerGoCommands } from "./cli/goCommands.js";
import { registerRustCommands } from "./cli/rustCommands.js";
import { registerSqliteCommands } from "./cli/sqliteCommands.js";
import { registerAnalysisViewCommands } from "./cli/analysisViewCommands.js";
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
@@ -102,7 +101,6 @@ export const createCli = (
registerSignalCommands(cli, logger, environment);
registerEvmCommands(cli, logger, environment);
registerGoCommands(cli, logger);
registerRustCommands(cli, logger);
registerSqliteCommands(cli, logger, environment);
registerArtifactCommands(cli, logger, analysis.runProviderAnalysis);
registerManagedCommands(cli, logger, analysis.runProviderAnalysis);
+2 -2
View File
@@ -56,13 +56,13 @@ const registerInspectManagedMembers = (
): void => {
cli.command(CLI_COMMANDS.inspectManagedMembers, {
description:
"Inspect PE/CLI metadata members, signatures, CIL anchors, and ldstr string literals without loading target code",
"Inspect PE/CLI metadata members, signatures, and CIL anchors without loading target code",
args: z.object({
path: z.string().describe("Managed PE executable or assembly path"),
}),
options: z.object({
type: managedMembersInputSchema.shape.type_names.describe(
"Inspect only members declared by this exact type full name, and the string literals they load, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)",
"Inspect only members declared by this exact type full name, such as System.Threading.Monitor; a nested type is named by its bare name, which selects every nested type of that name (repeatable)",
),
}),
run: ({ args, options }) =>
-34
View File
@@ -1,34 +0,0 @@
import { z } from "incur";
import { resolve } from "node:path";
import { createRustBinaryService } from "../composition/rust.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Resolve caller paths and inspect embedded Rust dependencies through the shared workflow. */
export const registerRustCommands = (
cli: CliInstance,
logger: Logger,
): void => {
const service = createRustBinaryService();
cli.command(CLI_COMMANDS.inspectRustBinary, {
description:
"Inspect an embedded cargo-auditable Rust dependency list without target execution",
args: z.object({
path: z.string().describe("Local ELF, PE or thin Mach-O image"),
}),
run: ({ args }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectRustBinary, async () => {
const result = await service.inspect(
{ path: resolve(args.path) },
{ signal },
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
};
-1
View File
@@ -50,7 +50,6 @@ export const CLI_COMMANDS = Object.freeze({
inspectRecordedCrash: "inspect-recorded-crash",
inspectBinaryLayout: "inspect-binary-layout",
inspectGoBinary: "inspect-go-binary",
inspectRustBinary: "inspect-rust-binary",
inspectPeResources: "inspect-pe-resources",
inspectAnalysisView: "inspect-analysis-view",
listFridaDevices: "list-frida-devices",
-6
View File
@@ -1,6 +0,0 @@
import { RustBinaryService } from "../application/rust/RustBinaryService.js";
import { RustBinaryProvider } from "../rust/RustBinaryProvider.js";
/** Compose bundled offline inspection without a Rust toolchain or any child process. */
export const createRustBinaryService = (): RustBinaryService =>
new RustBinaryService(new RustBinaryProvider());
-34
View File
@@ -249,40 +249,6 @@ describe("configuration failure diagnostics", () => {
},
);
it.each(["1", "90000", "2147483647"])(
"accepts Swift compile deadline %s without changing Hopper's deadline",
(value) => {
expect(
parseConfig({ REA_SWIFTC_COMPILE_TIMEOUT_MS: value }),
).toMatchObject({
ok: true,
value: {
hopperStartupTimeoutMs: 120_000,
},
});
},
);
it.each(["", "0", "-1", "1.5", "1e3", "invalid", "2147483648"])(
"rejects malformed Swift compile deadline %j with its setting",
(value) => {
const result = parseConfig({ REA_SWIFTC_COMPILE_TIMEOUT_MS: value });
if (result.ok) throw new Error("Expected invalid Swift compile deadline");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "configuration_invalid",
category: "invalid_input",
details: {
settings: [
{
setting: "REA_SWIFTC_COMPILE_TIMEOUT_MS",
constraint: expect.any(String),
},
],
},
});
},
);
it.each([
["GHIDRA_INSTALL_DIR", "./fixture"],
["JAVA_HOME", "./jdk"],
-4
View File
@@ -12,7 +12,6 @@ import { ghidraDisabledAnalyzersSchema } from "./ghidraDisabledAnalyzers.js";
import {
DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
providerStartupTimeoutSchema,
} from "./providerStartupTimeout.js";
import { mcpInputSchemaProfileSchema } from "./mcpInputSchemaProfile.js";
@@ -48,9 +47,6 @@ const environmentSchema = z.object({
REA_HOPPER_STARTUP_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_HOPPER_STARTUP_TIMEOUT_MS,
),
REA_SWIFTC_COMPILE_TIMEOUT_MS: providerStartupTimeoutSchema.default(
DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS,
),
REA_GHIDRA_LANGUAGE_ID: ghidraLanguageIdSchema.optional(),
REA_GHIDRA_COMPILER_SPEC_ID: ghidraCompilerSpecIdSchema.optional(),
REA_GHIDRA_DISABLED_ANALYZERS: ghidraDisabledAnalyzersSchema.optional(),
-4
View File
@@ -6,10 +6,6 @@ export const DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS = 330_000;
/** Default absolute deadline for Hopper launch, analysis, bridge, and readiness. */
export const DEFAULT_HOPPER_STARTUP_TIMEOUT_MS = 120_000;
/** Default deadline for Darwin ownership-helper `swiftc` compilation. */
/** Fresh Swift SDK cache took ~88s on the supported Intel macOS VM. */
export const DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS = 120_000;
// Node schedules delays above this signed 32-bit boundary as a 1 ms timer.
const MAX_TIMER_DELAY_MS = 2_147_483_647;
+1 -1
View File
@@ -79,7 +79,7 @@ const artifact = <
export const ARTIFACT_TOOL_CONTRACTS = [
artifact(
"inspect_artifact",
"Inspect an active archive or application package in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Optional detail summary retains the complete inspection and returns its derived summary view; read exact paths or inventory pages with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
"Inspect an active archive or application package in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. Optional detail summary retains the complete inspection and returns its derived summary view; read exact paths or inventory pages with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Classic iNES includes header facts, PRG/CHR file-region hashes and byte-exact reconstruction evidence; mapper execution and CPU bank mappings remain unobserved. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize its regular contents.",
artifactInspectionInputSchema,
),
artifact(
@@ -43,7 +43,7 @@ export const managedMembersInputSchema = managedTargetInputSchema.extend({
})
.meta({ uniqueItems: true })
.describe(
"Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names, and the user strings their method bodies load; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.",
"Inspect only the fields, methods, call edges, and field accesses declared by these exact type full names; omit to inspect every type. Unmatched names are reported in selection.unmatched_type_names.",
)
.optional(),
});
@@ -87,7 +87,7 @@ export const MANAGED_TOOL_CONTRACTS = [
name: "inspect_managed_members",
...toolContractMetadata("inspect_managed_members"),
description:
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
"Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, and field-access anchors without loading or executing target code. Select type_names to decode only the members of those exact declaring types, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow.",
kind: "managed-provider",
inputSchema: managedMembersInputSchema,
outputSchema: memberOutputSchema,
@@ -53,7 +53,6 @@ const emptyManagedMembers = (
member_refs: [],
call_edges: [],
field_accesses: [],
user_strings: { state: "complete", literals: [], issues: [] },
selection: null,
coverage: { state: "complete", issues: [] },
limitations: [],
-26
View File
@@ -1,26 +0,0 @@
import {
inspectRustBinaryInputSchema,
rustBinarySchema,
} from "../../domain/rust/rustBinary.js";
import type { ToolContract } from "../toolContractTypes.js";
import { toolContractMetadata } from "../toolEffects.js";
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
/** Static Rust dependency metadata is independent of active native-analysis providers. */
export const RUST_TOOL_CONTRACTS = [
{
name: "inspect_rust_binary",
...toolContractMetadata("inspect_rust_binary"),
kind: "artifact-provider",
description:
"Inspect the cargo-auditable crate dependency list embedded in an explicit local ELF, PE or thin Mach-O image. Returns the artifact SHA-256, the .dep-v0 section file offset and size, compressed and uncompressed sizes, format version, packages in their original order, the root package index, and dependency edges by index. Reads zlib-compressed JSON without installing Rust, opening a disassembler, executing the target, writing temporary files or accessing the network. A missing section is null metadata with a limitation and is not proof that the binary has no dependencies. A rustc commit hash is reported only as an inference when a /rustc/<40-hex>/ path is present in read-only data. Resource guards bound files to 256 MiB, dependency sections to 1 MiB, inflated JSON to the smaller of 8 MiB and 64 times the compressed size, and the complete report to 8 MiB. Malformed, unsupported and oversized inputs receive typed failures rather than partial success.",
inputSchema: inspectRustBinaryInputSchema,
outputSchema: evidenceResultOf(rustBinarySchema),
examples: [
{
title: "Inspect an embedded Rust dependency list",
input: { path: "/artifacts/application" },
},
],
},
] as const satisfies readonly ToolContract[];
-2
View File
@@ -3,7 +3,6 @@ import { SIGNAL_TOOL_CONTRACTS } from "./signals/signalToolContracts.js";
import { WASM_TOOL_CONTRACTS } from "./wasm/wasmToolContracts.js";
import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { GO_TOOL_CONTRACTS } from "./go/goToolContracts.js";
import { RUST_TOOL_CONTRACTS } from "./rust/rustToolContracts.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";
@@ -41,7 +40,6 @@ export const TOOL_CONTRACTS = [
...ARTIFACT_TOOL_CONTRACTS,
...EVM_TOOL_CONTRACTS,
...GO_TOOL_CONTRACTS,
...RUST_TOOL_CONTRACTS,
...WASM_TOOL_CONTRACTS,
...SQLITE_TOOL_CONTRACTS,
...CAPTURE_TOOL_CONTRACTS,
+4 -20
View File
@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { TOOL_CONTRACTS } from "./toolContracts.js";
import {
TOOL_EFFECTS,
annotationsFromEffects,
type ToolEffects,
} from "./toolEffects.js";
import { TOOL_EFFECTS, annotationsFromEffects } from "./toolEffects.js";
/**
* Descriptions that name running, launching, executing, starting, installing,
@@ -17,14 +13,13 @@ const EXECUTION_WORDING =
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
inspect_artifact: "states that mapper execution remains unobserved",
start_frida_session:
"spawned targets remain suspended until resume_frida_session",
inspect_native_load_image: "states that it does not execute the target",
resolve_native_call_targets: "states that it does not execute code",
inspect_pe_resources: "states that it does not execute the image",
inspect_go_binary: "reads embedded metadata without executing the target",
inspect_rust_binary:
"reads an embedded dependency list without executing the target",
inspect_sqlite_database:
"uses a trusted inspector worker without executing application code or caller SQL",
inspect_binary_layout:
@@ -152,19 +147,8 @@ describe("tool effects", () => {
});
it("derives destructiveHint from each destructive effect alone", () => {
// Keep annotation algebra independent of any operation's audited effects.
const base: ToolEffects = {
mutatesTarget: false,
mutatesSession: false,
writesFilesystem: false,
launchesProcess: false,
accessesNetwork: false,
changesUiState: false,
mayDiscardData: false,
executesForeignCode: false,
mutatesDevice: false,
idempotent: true,
};
const base = TOOL_EFFECTS["binary_session"];
if (base === undefined) throw new Error("binary_session is unaudited");
expect(annotationsFromEffects(base)).toEqual({
readOnlyHint: true,
destructiveHint: false,
+1 -2
View File
@@ -54,7 +54,6 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_go_binary: evidence,
inspect_rust_binary: evidence,
inspect_sqlite_database: effects({
mutatesSession: true,
writesFilesystem: true,
@@ -605,7 +604,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
mayDiscardData: true,
idempotent: false,
}),
binary_session: effects({ launchesProcess: true }),
binary_session: effects(),
export_evidence_bundle: effects({
writesFilesystem: true,
mayDiscardData: true,
@@ -62,6 +62,7 @@ export const targetFormatSchema = z.enum([
"raw-binary",
...ZIP_PACKAGE_FORMATS,
"asar",
"ines",
"dmg",
"pkg",
"plist",
+1
View File
@@ -69,6 +69,7 @@ const formatSchema = z.enum([
"hsp",
"app-pack",
"asar",
"ines",
"dmg",
"pkg",
"plist",
+2 -3
View File
@@ -68,7 +68,7 @@ export const androidApplicationProjectionResultSchema = z.strictObject({
// Count completeness is separate from the declared coverage status.
inventory_complete: z.boolean(),
}),
limitations: z.array(z.string()),
limitations: z.array(z.string().min(1)),
});
export type AndroidApplicationProjectionInput = z.infer<
@@ -99,8 +99,7 @@ export const projectAndroidApplication = (
);
const components = classified;
const limitations = [
...inventory.limitations,
...(!hasCompleteInventoryCoverage(inventory, "container-entries")
...(!hasCompleteInventoryCoverage(inventory)
? ["Source inventory coverage is partial; absence is unknown."]
: []),
"Manifest, resource, signing, and bytecode semantics require a dedicated Android provider; this projection reports exact inventory paths and hashes only.",
+12 -18
View File
@@ -94,7 +94,7 @@ export const appleApplicationProjectionResultSchema = z.strictObject({
// Count completeness is separate from the declared coverage status.
inventory_complete: z.boolean(),
}),
limitations: z.array(z.string()),
limitations: z.array(z.string().min(1)),
});
export type AppleApplicationProjectionInput = z.infer<
@@ -169,24 +169,18 @@ export const projectAppleApplication = (
);
const uninventoriedImage =
rootFormat === "dmg" && inventory.occurrences.length === 1;
const inventoryComplete = hasCompleteInventoryCoverage(
inventory,
"container-entries",
);
const inventoryComplete = hasCompleteInventoryCoverage(inventory);
const complete = inventoryComplete && !uninventoriedImage;
const limitations = [
...inventory.limitations,
...projectionLimitations({
complete: inventoryComplete,
uninventoriedImage,
roots,
bundles,
sidecars: entries.some(({ path }) => isSidecar(path)),
symlinks: entries.some(({ kind }) => kind === "symlink"),
unattributed,
bridgeCandidateCoverage: bridgeProjection.coverage,
}),
];
const limitations = projectionLimitations({
complete: inventoryComplete,
uninventoriedImage,
roots,
bundles,
sidecars: entries.some(({ path }) => isSidecar(path)),
symlinks: entries.some(({ kind }) => kind === "symlink"),
unattributed,
bridgeCandidateCoverage: bridgeProjection.coverage,
});
const withoutId = {
root_sha256: inventory.manifest.root_sha256,
root_format: rootFormat,
+12 -53
View File
@@ -104,51 +104,13 @@ export const compareArtifacts = (
const right = parseArtifactInventoryEvidence(rightEvidence);
const leftComplete = left.inventory.complete;
const rightComplete = right.inventory.complete;
const sameScope =
left.inventory.coverage.scope === right.inventory.coverage.scope;
const rootCoverageComplete =
sameScope &&
left.inventory.complete &&
right.inventory.complete &&
left.inventory.coverage.status === "complete" &&
right.inventory.coverage.status === "complete";
const entryCoverageComplete =
rootCoverageComplete &&
left.inventory.coverage.scope === "container-entries";
const leftCovered =
sameScope &&
hasCompleteInventoryCoverage(left.inventory, left.inventory.coverage.scope);
const rightCovered =
sameScope &&
hasCompleteInventoryCoverage(
right.inventory,
right.inventory.coverage.scope,
);
const fullyCovered =
leftCovered &&
rightCovered &&
(entryCoverageComplete ||
(rootCoverageComplete &&
left.inventory.manifest.occurrence_count === 1 &&
right.inventory.manifest.occurrence_count === 1));
const leftCovered = hasCompleteInventoryCoverage(left.inventory);
const rightCovered = hasCompleteInventoryCoverage(right.inventory);
const limitations = [
...left.inventory.limitations.map((item) => `Left: ${item}`),
...right.inventory.limitations.map((item) => `Right: ${item}`),
...(leftComplete ? [] : ["Left artifact inventory is incomplete."]),
...(rightComplete ? [] : ["Right artifact inventory is incomplete."]),
...(sameScope ? [] : ["Artifact inventory scopes differ."]),
...(sameScope &&
left.inventory.coverage.scope === "root" &&
(left.inventory.manifest.occurrence_count > 1 ||
right.inventory.manifest.occurrence_count > 1)
? ["Container entry coverage is unavailable for child paths."]
: []),
...(left.inventory.coverage.status === "complete"
? []
: ["Left artifact inventory traversal coverage is unknown."]),
...(right.inventory.coverage.status === "complete"
? []
: ["Right artifact inventory traversal coverage is unknown."]),
];
const changes = compareOccurrences(
left.inventory,
@@ -157,7 +119,7 @@ export const compareArtifacts = (
...left.evidence.map(({ evidence_id: id }) => id),
...right.evidence.map(({ evidence_id: id }) => id),
],
{ root: rootCoverageComplete, entries: entryCoverageComplete },
leftCovered && rightCovered,
);
applyRootHashChange(left.inventory, right.inventory, changes, [
...left.evidence.map(({ evidence_id: id }) => id),
@@ -167,7 +129,7 @@ export const compareArtifacts = (
left.inventory,
right.inventory,
changes,
fullyCovered,
leftCovered && rightCovered,
);
return artifactComparisonResultSchema.parse({
status:
@@ -177,7 +139,7 @@ export const compareArtifacts = (
({ classification }) => classification === "contradiction",
)
? "contradiction"
: !fullyCovered
: !leftCovered || !rightCovered
? "unknown"
: changes.some(({ classification }) => classification === "unknown")
? "unknown"
@@ -196,7 +158,7 @@ const compareOccurrences = (
left: InventorySet,
right: InventorySet,
evidenceLinks: readonly string[],
coverage: { readonly root: boolean; readonly entries: boolean },
complete: boolean,
): ArtifactChange[] => {
const leftByPath = new Map(
left.occurrences.map((item) => [item.logical_path, item]),
@@ -225,8 +187,7 @@ const compareOccurrences = (
rightNode: nodeFor(rightOccurrence, rightNodes),
leftRelations: leftRelations.get(path) ?? [],
rightRelations: rightRelations.get(path) ?? [],
rootCovered: coverage.root,
entriesCovered: coverage.entries,
complete,
evidenceLinks,
});
if (change !== null) output.push(change);
@@ -242,8 +203,7 @@ interface ClassifyPathInput {
readonly rightNode: ArtifactNode | undefined;
readonly leftRelations: readonly RelationProjection[];
readonly rightRelations: readonly RelationProjection[];
readonly rootCovered: boolean;
readonly entriesCovered: boolean;
readonly complete: boolean;
readonly evidenceLinks: readonly string[];
}
@@ -257,18 +217,17 @@ const buildArtifactChangeBase = (input: ClassifyPathInput) => ({
});
const classifyPresence = (input: ClassifyPathInput): ArtifactChange | null => {
const covered = input.path === "." ? input.rootCovered : input.entriesCovered;
if (input.leftOccurrence === undefined)
return {
...buildArtifactChangeBase(input),
classification: covered ? "added" : "unknown",
dimensions: [covered ? "content" : "availability"],
classification: input.complete ? "added" : "unknown",
dimensions: [input.complete ? "content" : "availability"],
};
if (input.rightOccurrence === undefined)
return {
...buildArtifactChangeBase(input),
classification: covered ? "removed" : "unknown",
dimensions: [covered ? "content" : "availability"],
classification: input.complete ? "removed" : "unknown",
dimensions: [input.complete ? "content" : "availability"],
};
return null;
};
+12 -13
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { inesInventorySchema } from "./inesContainer.js";
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
const artifactIdSchema = prefixedDigestSchema("art");
@@ -42,6 +43,7 @@ const artifactFormatSchema = z.enum([
"zip",
"pkg",
"asar",
"ines",
"javascript-bundle",
"source-map",
"mach-o-universal",
@@ -69,6 +71,7 @@ const artifactFormatSchema = z.enum([
/** Formats established from bytes or a virtual directory tree, independent of path roles. */
const artifactContentFormatSchema = artifactFormatSchema.extract([
"ines",
"zip",
"mach-o-universal",
"mach-o",
@@ -188,11 +191,8 @@ export const artifactGraphManifestSchema = z.object({
edge_count: z.number().int().min(0),
});
/** Producer-owned traversal coverage; content certainty remains on each occurrence. */
export const artifactInventoryCoverageSchema = z.strictObject({
scope: z.enum(["root", "container-entries"]),
status: z.enum(["complete", "unknown"]),
});
/** Whether the inventory producer covered its modeled child-entry scope. */
export const artifactInventoryCoverageSchema = z.enum(["complete", "partial"]);
export type ArtifactInventoryCoverage = z.infer<
typeof artifactInventoryCoverageSchema
>;
@@ -205,19 +205,18 @@ export const artifactInventoryResultSchema = z.object({
edges: z.array(artifactEdgeSchema),
provenance: z.array(artifactCommandSchema),
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
coverage: artifactInventoryCoverageSchema.optional(),
ines: inesInventorySchema.optional(),
limitations: z.array(z.string()),
/** Optional only for persisted inventory Evidence written before this field. */
coverage_status: z.enum(["complete", "partial"]).optional(),
coverage_status: artifactInventoryCoverageSchema.optional(),
});
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
/** Current inventory producers always classify the coverage they examined. */
export const artifactInventoryOutputSchema =
artifactInventoryResultSchema.safeExtend({
coverage: artifactInventoryCoverageSchema,
coverage_status:
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
artifactInventoryResultSchema.extend({
coverage_status: artifactInventoryCoverageSchema,
// JSON output may omit container facts, but cannot carry an undefined member.
ines: inesInventorySchema.exactOptional(),
});
export type ArtifactInventoryOutput = z.infer<
typeof artifactInventoryOutputSchema
+22 -1
View File
@@ -17,7 +17,12 @@ const artifactIdSchema = prefixedDigestSchema("art");
const inspectionObservationSchema = z.strictObject({
observation_id: prefixedDigestSchema("aio"),
kind: z.enum(["root-manifest", "integrity"]),
kind: z.enum([
"root-manifest",
"integrity",
"container-header",
"reconstruction",
]),
subject: textSchema,
value: jsonValueSchema,
evidence_id: evidenceIdSchema,
@@ -228,6 +233,22 @@ const allObservations = (
...inventory.integrity_contradictions.map((value) =>
observation("integrity", value.contradiction_id, value, evidenceId),
),
...(inventory.ines === undefined
? []
: [
observation(
"container-header",
inventory.manifest.root_artifact_id,
inventory.ines.header,
evidenceId,
),
observation(
"reconstruction",
inventory.manifest.root_artifact_id,
inventory.ines.reconstruction,
evidenceId,
),
]),
];
const observation = (

Some files were not shown because too many files have changed in this diff Show More