Compare commits

..
Author SHA1 Message Date
morluto f2cffbdfdc fix(pcap): preserve executable read failures 2026-10-22 06:09:50 +08:00
morluto 5e046bb374 fix(pcap): read historical capture evidence 2026-10-22 06:05:55 +08:00
morluto 4924706e25 fix(pcap): report missing selected tools clearly 2026-10-22 05:52:17 +08:00
morluto 211d4b2219 perf(pcap): retain compact executable identity 2026-10-22 05:47:11 +08:00
morluto cb6dc4f3ef fix(pcap): share selected tool identity verification 2026-10-22 05:40:52 +08:00
Kaoru0822andClaude Opus 5.5 658c29b7fc chore: restore main's copies of files the merge hook reformatted
The pre-commit oxlint --fix touched two files this PR does not change; keep them identical to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:22:28 +00:00
Kaoru0822andClaude Opus 5.5 a22adc7809 Merge origin/main into refactor/tshark-single-owner
Resolve the node:fs/promises import conflict in pcapProviderCrossPlatform.test.ts by importing open, symlink and writeFile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 03:04:35 +00:00
morluto d0067ba34a fix(native)!: preserve Swift pointer nullability and import bindings
Resolve relative pointers using field-owned ABI encodings. Keep null, raw, rebased, imported and unknown targets distinct, and retain imported conformance rows with exact Mach-O binding provenance. Decode legacy rebase membership with source-derived memory and work bounds, preserving validated prefix facts when streams are incomplete.

Validation: check:ci, docs:check and build passed; 158 source tests and 20 schema/acceptance tests passed. Ten source-built Apple dispatch variants and four issue-specific CLI/MCP workflows passed without executing targets. Paired cold/warm CLI/MCP cost observations retained locally; model tokens and maximum-sized input runtime were not measured.

Closes #5683
Closes #5685

BREAKING CHANGE: Swift conformance type_name and protocol_name can be null when names cannot be resolved; consumers must use typed type_binding and protocol_binding evidence. MachoImportBinding replaces the former Objective-C-specific binding type and schema.
2026-10-21 08:17:12 +08:00
morluto 9258df02cc test(apple): compare asset catalogs from matching root workflows
Compare explicit bundle-resource CLI and MCP results at the positive empty-inventory offset. Preserve a separate binary-bound MCP assertion with the exact fixture executable digest and complete metadata payload.

Validation: reproduced the stale root-versus-binary parity failure on current main, then passed the macOS public CLI/MCP acceptance case across all five NIB fixtures. Focused lint, formatting and diff checks passed. No target code was executed.
2026-10-21 07:37:36 +08:00
morluto 1502cfbc8c fix(apple): resolve absolute dylib paths inside the selected root
Preserve load-command paths as evidence while passing a separate root-relative lookup path to the bounded tree resolver. Apply the same containment rule to absolute install names and rpath bases, including caller-selected root symlink spellings.

Validation: 140 source tests, full check:ci, docs and runtime build passed. Compiler-built literal, rpath and relative fixtures passed CLI/stdio MCP parity, preserved both dependency edges and left all bytes unchanged. External system paths stayed outside-target; relative control was unchanged. Fixtures were not executed, and actual dyld execution was not qualified.

Closes #5637
2026-10-21 07:37:15 +08:00
morluto 84c89d1107 test(javascript): separate heap pressure from graph admission proof
Match memory failures to the exact source identity instead of graph ordering. Preserve CLI parse, file-evidence, partial-result, later-source and unchanged-input checks across valid memory-pressure outcomes. Use a controlled parent heap envelope with a real owned analyzer to prove module retention and graph rejection independently of host GC timing.

Validation: full check:ci passed; 17 focused tests passed, with the final 4 changed tests rerun successfully. Independent test-strength and isolation review passed. The earlier hosted failure exposed missing graph-only fields; its exact alternate memory stage was not captured.
2026-10-21 07:26:20 +08:00
morluto 0bd14ca16c fix(browser): preserve uncertain main-frame navigation identity
Own CDP frame applicability and identity at the parsed event boundary. Preserve malformed or incomplete main-frame facts, recognize replacement roots, and carry the same document identity through captures, screenshots, timelines, and execution windows.

Validation: full check:ci and docs:check; 763 browser and dispatch tests passed, 6 skipped. Independent source and fixture-lifecycle review passed. Local real-Chrome behavior was not exercised.

Closes #5665
2026-10-21 07:16:47 +08:00
morluto 9dee989a79 fix(native): count Objective-C coverage by class object
Count each admitted unique class or metaclass object at its parsing owner. Preserve emitted metadata, independent class-list scan bounds, partial failures, and truncation.

Closes #5706
2026-10-21 07:01:17 +08:00
morluto f789fd1fd8 fix(native): count Swift vtable coverage in slot units
Count examined and decoded vtable slots in the same population, preserving all returned slots and partial descriptor failures. Document the coverage unit and retain malformed-sibling and unresolved-slot checks.

Closes #5681
2026-10-21 06:56:18 +08:00
morluto ccc7da309d test(mcp): retain distinct Objective-C class observations
Require both fixture addresses and unknown identity coverage when no selected-image binding proof permits coalescing.
2026-10-21 06:45:34 +08:00
morluto 56679dd2ae fix(files): preserve physical read provenance through cleanup
Keep raw physical read diagnostics alongside explicit operation provenance on the same cleanup owner. Preserve provenance after successful close retry for opted-in readers, restore legacy error projections, and prevent borrowed cleanup-shaped failures from authorizing foreign resource retries.

Refs #5686
2026-10-21 06:45:34 +08:00
morluto 52c84f6f58 test(cli): bound ADB MCP validation to the advertised tool
Reuse the exact selected advertised descriptor for SDK output validation instead of compiling the entire cached catalog for every fresh parity-test client. Retain actual catalog discovery, schema validation, and all result assertions under the existing worker heap limit.
2026-10-21 06:23:20 +08:00
morluto 4852c830af fix(native): preserve Mach-O names equal to version
Parse only documented compound otool field labels so valid segment and section names are retained at the owning boundary. Verified real Apple clang fixtures through compiled CLI and stdio MCP without executing the targets.

Closes #5680
2026-10-21 06:19:43 +08:00
morluto 949966712e fix(files): preserve physical read failures across late cancellation
Tag physical read outcomes at the owning boundary and preserve opaque caller cancellation reasons through JSON pipelines and reference imports. Share named bounded-read options across consumers and retain failed cleanup ownership for retry.

Closes #5686
2026-10-21 06:14:03 +08:00
morluto 4d7de00d4c fix(native): coalesce Objective-C class imports by binding identity
Join provider class observations only when selected-image identity and exact import bindings establish equivalence. Preserve distinct observations and unsupported coverage.

Refs #5607
2026-10-21 06:12:21 +08:00
morluto b231c28cf6 fix(hopper): own Mach-O preparation through cleanup retry
Preserve exact selected-image identity and descriptor ownership across preparation, final cleanup, and retries. Keep physical failures and failed cleanup leases observable through provider and launcher boundaries.

Closes #5688
2026-10-21 06:12:09 +08:00
morluto 7dd6941393 refactor(test): share browser and apktool fixture lifecycles 2026-10-21 05:50:50 +08:00
morluto 1bb2fa23de test(apple): match plist provenance to the selected workflow 2026-10-21 05:50:50 +08:00
morluto ab58edcdf5 fix(environment): retain tarball descriptor ownership during export
Use the shared positional reader for both tarball passes so early output refusal cannot close the descriptor ahead of its owner.

Closes #5684
2026-10-21 05:33:35 +08:00
morluto 60956c8c56 fix(ci): run cache retention from its trusted workflow revision 2026-10-21 05:28:08 +08:00
morluto 5518363aa6 test(artifacts): compare matching resource-root workflows 2026-10-21 05:28:03 +08:00
morluto 4108d1363a test(cli): align Kuna readiness and cancellation expectations 2026-10-21 05:27:56 +08:00
morluto 0bc2b42001 fix(browser)!: preserve unknown WebSocket frame facts
Keep malformed or missing CDP opcodes and payload facts unknown, retain observed zero and empty payloads, and avoid false capture differences while preserving independently known facts.

Validation: 59 source tests; compiled public CLI and stdio MCP fake-CDP acceptance; check:fast; docs:check; changed-file formatting. Real Chromium malformed-event behavior was not tested.

BREAKING CHANGE: WebSocket event opcode is nullable and numeric values are restricted to the four-bit protocol range.

Fixes #5666
2026-10-21 05:00:37 +08:00
c38f96fa69 test(cli): keep IPC trace input under the Linux spawn limit (#5673)
* test(cli): keep IPC trace input under the Linux spawn limit

The listener-callsite CLI builds one trace argument from the full
application Evidence document. On 4 KiB-page Linux that string exceeds
MAX_ARG_STRLEN, so spawn throws E2BIG before the command starts. Keep the
document in a file, require the spawned argv to fit the single-argument
budget, and lock that budget through the test process registry.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(process): size the spawn ceiling from ARG_MAX and MAX_ARG_STRLEN

A payload at the single-string limit still fails execve with E2BIG when
the stack shrinks ARG_MAX below the rest of the image. Accept only the
bytes that fit both budgets, and skip that success spawn when the fixed
argv and environment already fill ARG_MAX.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* style(test): format files rejected by oxfmt in static checks

check:ci:fast failed format:check on the CLI fixture import and, after
merging main, on the admission test's new command-abort case.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 04:48:33 +08:00
Ulises Millán c4b6aa3061 fix(managed): treat extension-only module scopes as unknown identity (#5674)
Closes #5467
2026-10-21 04:35:45 +08:00
morluto dceefa22cb Merge remote-tracking branch 'origin/main' into codex/audit-continuation-20261020-refresh20 2026-10-21 04:22:27 +08:00
morluto 6a6adf703f fix(resources)!: inspect app resources without an executable
Add a read-only app resource session shared by CLI and MCP. Preserve resource-root provenance without inventing executable identity, reject binary-only snapshots and operations, and retain failed cleanup owners across switches and retries.

Keep filesystem admission failures typed and preserve cancellation ordering from observed cleanup facts. Document resource routing in the packaged investigation skill.

BREAKING CHANGE: resource-only observations may have a null Evidence subject and null executable target digests. Clients must use resource_root and reported per-resource provenance instead.

Refs #5589
2026-10-21 04:22:20 +08:00
de4579e993 fix(observation): keep incomplete cancellation observations (#5644)
* fix(observation): keep incomplete cancellation observations

Service projection treated a retained observation that failed the completed
schema as a malformed completed result. Cancellation became unreadable_output
and the partial was dropped. Incomplete observations now stay on the owner
error, including the exact abort reason.

* fix(observation): drop non-JSON incomplete observations

Keep a retained partial only when projectAnalysisError can serialize it.
Clear a non-JSON partial and record an AnalysisOutputError as a secondary
failure so cancellation and timeout stay the owner error.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): validate incomplete partials without copying

jsonValueSchema.safeParse cloned a JSON-safe partial that projection
copies again. Use jsonValueValidationIssue so the gate only checks.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(ida): expect non-retryable unresolved procedure lookups

Main stops treating invalid_request as retryable. An unchanged missing
procedure stays invalid_request and is not marked retryable.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): share incomplete-observation assertions

The new non-JSON cases repeated the same owner-error checks and pushed
duplication over the 3.5% limit. One expectation helper keeps those
checks without a second copy.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): keep the assertion helper out of the package

The helper imports Vitest. Rename it to a fixture so the production
build excludes it and dist does not import a dev dependency.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): clear non-JSON partials on cleanup primaries

Projection can return the cleanup primary after retry. Clearing only
the wrapper left that primary's partial in place, so projectAnalysisError
threw. The gate now clears every non-JSON partial in the chain.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): freeze retained incomplete observations

A JSON partial stayed a live provider object, so a later mutation could
make projection throw. The gate now keeps one frozen snapshot and still
drops a non-JSON partial without copying it.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* refactor(observation): share web runtime partial retention

Observe and inspect used the same promote-or-keep path. One helper
owns that sequence so the two operations cannot drift.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): snapshot a shared partial once

A cleanup wrapper and its primary can hold the same partial object.
The gate now reuses the first frozen snapshot for that object.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* fix(observation): parse a kept partial once

jsonValueSchema.safeParse validates and copies only a JSON partial.
The gate freezes that copy and no longer walks the same value first.

Co-authored-by: morluto <morluto@users.noreply.github.com>

* test(observation): share repeated observation fixtures

The incomplete-observation tests repeated the same provider setup and
an empty Electron coverage tail, which pushed duplication over 3.5%.
One coverage fixture and shared assertions bring the tree back under
the threshold.

Co-authored-by: morluto <morluto@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: morluto <morluto@users.noreply.github.com>
2026-10-21 04:17:46 +08:00
555aaditya e74e4d8f0b Merge remote-tracking branch 'origin/main' into refactor/tshark-single-owner 2026-10-14 16:28:29 +05:30
555aaditya f4eeba3654 fix(pcap): execute the TShark the shared locator selects
resolvePcapTools still returned bare tool names for PATH selections, so
the operating system could run a tool from a relative PATH entry while
the packet tools skipped that entry and selected a different install.

On POSIX hosts, resolve PATH selections through locateWiresharkTool and
run that absolute executable, failing with the existing tshark_missing or
capinfos_missing code when no absolute PATH entry holds the tool. Both
selections are validated before PATH is consulted, so a relative
environment value still reports its own configuration error. Windows
keeps PATH/PATHEXT resolution in the process launcher.

Refs #3403
2026-10-14 16:28:29 +05:30
555aaditya bbecf6fb05 refactor(pcap): give TShark selection and version admission one owner
The pcap and packet-capture families wrapped the same caller-supplied
TShark with diverging rules. inspect_pcap_* accepted an absolute
REA_TSHARK_COMMAND or tshark from PATH, while inspect_packet_* and the
server availability policy required an absolute REA_TSHARK_COMMAND and
parsed the version banner with a separate regex.

PcapConfiguration now owns the rule: selectWiresharkTool selects an
absolute environment path or the bare name on PATH and refuses relative
values; locateWiresharkTool resolves a PATH selection to the first
absolute PATH entry holding an executable file; parseWiresharkVersion
admits Wireshark 4.x banners for both providers. TsharkCaptureProvider
and the session availability policy consume these facts instead of
reinterpreting REA_TSHARK_COMMAND, so all four tools accept and refuse
the same configurations. inspect_packet_* still resolves, hashes and
rechecks the located executable, and records selection_source in its
tool Evidence.

Host support and resource bounds are unchanged; unifying them is the
next milestone in #3403.

Refs #3403
2026-10-14 15:05:58 +05:30
211 changed files with 15776 additions and 2248 deletions

No files matched your search

@@ -94,6 +94,10 @@ explicit path or endpoint and do not need it.
- Archive/package member inventory (ZIP/APK/IPA/MSIX/AppX or DMG):
`open_binary` with the supplied local path; use `inspect_artifact` when its
graph and findings help answer the question.
- Apple app plist, Interface Builder, asset catalog, or keyed archive resources:
`open_bundle_resources`, then the selected resource tool. This does not
require `CFBundleExecutable` or a deep provider. Resource sessions have no
executable digest or binary snapshot; use `open_binary` for native code.
- Android APK code, classes, methods, or incoming references:
`inspect_android_package`, then focused Android tools when advertised.
Archive member inventory still uses the archive route above.
@@ -11,6 +11,22 @@ Select columns, batch IDs and page individual facts; aliases remain unresolved.
Unpaired observations do not prove additions/removals. Existing matched dossier
pairs include inputs for `compare_functions`; otherwise analyze just chosen pairs.
## Apple app resources
Use `open_bundle_resources` with the selected app path, then `inspect_plist`,
`decode_interface_builder`, `inspect_asset_catalog`, or `inspect_keyed_archive`.
The resource session reads the app without resolving `CFBundleExecutable` or
starting a deep provider. Preserve `resource_root` provenance, per-resource
hashes where reported, and null executable identity; a directory path is not
an executable digest. Export retained Evidence before closing or switching
roots. Binary analysis snapshots do not apply. Use `open_binary` to switch to
executable analysis, and `close_binary` to release either session kind.
The four matching CLI commands accept the app path directly. For an archive
inside the app, use `inspect-keyed-archive APP_PATH RELATIVE_ARCHIVE_PATH`.
Host requirements for plist and compiled asset/Interface Builder tooling still
apply; a resource-only session does not establish provider availability.
## Desktop installation directories
When an installation directory is supplied and its runtime is unknown, use
+16 -9
View File
@@ -18,10 +18,11 @@ concurrency:
jobs:
retain:
name: Retain Turbo snapshots and npm download caches
# Checkout and run only a same-repository default-branch commit. Pull
# requests, including forks, do not reach these steps. Static and build
# save Turbo snapshots before later lanes finish, so a failed, timed-out,
# or cancelled run still retains those snapshots.
# Restrict the triggering-source checkout to same-repository default-branch
# CI events. The helper runs from this trusted workflow revision; triggering
# source is used only for cache identity and is never executed. Static and
# build save snapshots before later lanes finish, so failed, timed-out, or
# cancelled runs still retain those snapshots.
if: >-
(github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure' ||
@@ -35,23 +36,29 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.workflow_sha }}
path: trusted-retention
persist-credentials: false
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.event.workflow_run.head_sha }}
path: triggering-source
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
node-version-file: trusted-retention/.nvmrc
- name: Retain current Turbo snapshots and npm download caches
env:
GH_TOKEN: ${{ github.token }}
CI_REPOSITORY: ${{ github.repository }}
CACHE_PREFIX: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-
CACHE_KEEP_KEY: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_KEEP_KEY: rea-static-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_DEEP_PREFIX: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-
CACHE_DEEP_KEEP_KEY: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('.nvmrc', '.npmrc', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_DEEP_KEEP_KEY: rea-static-deep-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('triggering-source/.nvmrc', 'triggering-source/.npmrc', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_KNIP_PREFIX: knip-meta-v1-${{ runner.os }}-
CACHE_KNIP_KEEP_KEY: knip-meta-v1-${{ runner.os }}-${{ hashFiles('knip.json', 'tsconfig.json', 'package.json', 'package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_KNIP_KEEP_KEY: knip-meta-v1-${{ runner.os }}-${{ hashFiles('triggering-source/knip.json', 'triggering-source/tsconfig.json', 'triggering-source/package.json', 'triggering-source/package-lock.json') }}-${{ github.event.workflow_run.head_sha }}
CACHE_PORTABLE_PREFIX: rea-portable-build-v2-${{ runner.os }}-${{ runner.arch }}-
DEDUPE_NPM_DOWNLOADS: "true"
run: node scripts/ci/cache-retention.mjs
run: node trusted-retention/scripts/ci/cache-retention.mjs
+7 -3
View File
@@ -228,9 +228,13 @@ root supplies. Every edge below it has `loader_conditional: true`, and a
request that reuses it by install name is `conditional` too. Findings on those
edges say that they apply only if the conditional image loads.
Absolute install names such as `/usr/lib/libSystem.B.dylib` are
`outside-target`. On macOS 11 and later most of them live in the dyld shared
cache rather than on disk, so REA does not check them against the host.
Absolute install names that name a path beneath the analyzed root are checked
within that root, with symlinks and `..` resolved by the same bounded tree
resolver. Absolute paths outside the root, such as
`/usr/lib/libSystem.B.dylib`, are `outside-target`; on macOS 11 and later most
system libraries live in the dyld shared cache rather than on disk, so REA does
not check them against the host. Mach-O path strings are not converted from
`file:` URLs or looked up through unrelated host paths.
`findings` are derived, not observed:
+1 -1
View File
@@ -362,7 +362,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
WebSocket coverage. Redirect resource types use only shared known metadata:
an omitted type leaves equality unknown rather than proving a modification.
- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim; 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.
- WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
- 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.
+11
View File
@@ -528,6 +528,17 @@ concrete version, selection source, and complete analysis profile through
choices return typed selection details. A selected provider is never replaced
automatically after a runtime failure.
`open_bundle_resources` selects a separate read-only app resource session
without resolving `CFBundleExecutable`, including when it is absent or
unresolvable. `binary_session` reports `session_kind: "bundle_resources"` and the
caller and canonical resource-root paths; it does not report a binary target or
deep-provider binding. `inspect_plist`, `decode_interface_builder`,
`inspect_asset_catalog`, and `inspect_keyed_archive` retain resource-root
provenance and use a null Evidence subject when executable identity is unknown.
Binary-only operations and binary analysis snapshots do not apply in this
session. `open_binary` can switch back to executable analysis, and
`close_binary` retires either session kind.
Each provider client receives `binary_session.analysis_run.run_id` before any
provider startup, including clients whose startup fails. `process_lineage` is
`not_observed` until a dynamic provider starts, then becomes `snapshots` with
+31 -1
View File
@@ -425,10 +425,25 @@ complete observation.
vtable descriptors. Records include exact virtual addresses/file offsets and
artifact evidence. Vtable indexes are metadata word offsets; witness indexes
start after the conformance header. Names unavailable in metadata remain null.
The `swift_class_vtable_descriptors` coverage counters count examined vtable
slots and successfully decoded slots, rather than type-section entries.
The `objc_class_method_ivar_metadata` counters count admitted class and
metaclass objects and successfully decoded objects.
Pointers are decoded through the image's fixups:
- `LC_DYLD_CHAINED_FIXUPS` rebases and binds, including the authenticated
arm64e formats;
- legacy `LC_DYLD_INFO` bind opcodes.
- legacy `LC_DYLD_INFO` rebase and bind opcodes.
A Swift conformance whose type or protocol reference is an external bind
keeps its name as `null` and carries the raw dyld identity in
`type_binding` or `protocol_binding`; that record and its facet coverage are
partial. Consumers of this output must handle nullable conformance names
and use the typed binding fields instead of substituting a symbol as a
display name. Indirect relative-pointer records also retain their binding
identity. A decoded rebase to virtual address zero remains an address; only
an unfixed zero in a nullable ABI field is treated as null. Incomplete or
conflicting fixup streams keep affected pointers unresolved; coverage reports
parsing and resource limits while retaining validated prefix facts.
So an external superclass resolves from its `_OBJC_CLASS_$_` bind (for
example `NSObject`). The `pointer_fixups` coverage facet names the mechanism.
@@ -466,6 +481,21 @@ complete observation.
that operation is unavailable; runtime failures remain failures rather than
being replaced by symbol-only results.
`get_objc_classes` keeps names from the provider inventory. It coalesces rows
only when their exact selected-image pointer slots decode to the same class
import, including its binding stream, raw library ordinal, resolved library, weak status, and addend. A literal filter is
applied to the original names before that join; if the filter selects only an
alias, the output keeps that exact row. `identity_coverage` distinguishes a
complete selected-inventory scan from partial, unsupported, and unknown cases.
Recognized Mach-O formats outside the native metadata reader's support retain
the provider's class labels with unknown import identity; malformed headers and
read failures remain errors.
The join also requires matching whole-artifact digests and matching provider-
owned selected-image facts: container, byte range, full raw CPU type/subtype,
and FAT table digest when present. A missing or overridden loader selection
remains unknown; the result describes agreement on the selected artifact image,
not an atomic observation of a running process or whole-program class coverage.
## Native desktop observation
`observe_native_ui` captures one explicitly selected existing PID/window ID.
+9 -2
View File
@@ -10,10 +10,17 @@ Supply an existing TShark 4.x installation **on Linux**, with util-linux
`prlimit` available at `/usr/bin/prlimit`. Override its absolute path with
`REA_TSHARK_PRLIMIT_COMMAND` if required. Unsupported hosts, including macOS
and Windows, are refused before inspection because this integration requires
an enforced address-space limit:
an enforced address-space limit.
TShark selection follows the same rule as [`inspect_pcap_capture`](pcap-analysis.md):
an absolute `REA_TSHARK_COMMAND`, otherwise `tshark` from the first absolute
`PATH` entry that holds an executable file. A relative `REA_TSHARK_COMMAND` is
refused rather than looked up. Either way the selected executable is resolved,
hashed and rechecked after each run, and Evidence records its
`selection_source` (`environment` or `path`):
```sh
export REA_TSHARK_COMMAND=/absolute/path/to/tshark
export REA_TSHARK_COMMAND=/absolute/path/to/tshark # optional when tshark is on PATH
rea inspect-packet-capture /artifacts/device.pcapng \
--fields ip.addr --fields udp.port --fields data.data \
--display_filter 'udp' --format json
+10 -1
View File
@@ -17,7 +17,16 @@ rea inspect-pcap-capture /captures/session.pcapng --json
```
Without explicit tool commands, this family resolves them through PATH and
records that selection source. Client inspection reads no capture file. Capture
records that selection source. On Linux and macOS it runs the tool found in the
first absolute PATH entry holding an executable file, the same selection the
[packet capture tools](packet-captures.md) make; relative PATH entries are
skipped. Windows keeps PATH/PATHEXT resolution in the process launcher. Client
inspection reads no capture file; on hosts where PATH/PATHEXT does not expose
an absolute candidate, its executable digest and resolved path are reported as
unresolved. Otherwise the canonical executable path and SHA-256 are reported.
The pcap provider and packet tools share the selected-tool identity check, and
changes to a resolved executable during a run fail with artifact-changed
Evidence. Capture
inspection requires Linux with util-linux prlimit or Windows with owned Job
Objects. macOS capture inspection returns a capability gap because the new
native memory boundary is unverified; its version probes remain available.
+14 -3
View File
@@ -58,8 +58,7 @@ export async function artifactCliEvidence(
return evidence;
}
/** Run a real stdio MCP subprocess, owning and closing its transport. */
export async function withArtifactMcp(target, verify) {
const withOpenedArtifactMcp = async (target, openTool, verify) => {
const transport = new StdioClientTransport({
command: process.execPath,
args: [fileURLToPath(new URL("../rea.mjs", import.meta.url)), "mcp"],
@@ -70,15 +69,27 @@ export async function withArtifactMcp(target, verify) {
try {
await client.connect(transport);
const opened = await client.callTool({
name: "open_binary",
name: openTool,
arguments: { path: target },
});
assert.notEqual(opened.isError, true, JSON.stringify(opened));
if (openTool === "open_bundle_resources")
assert.equal(opened.structuredContent?.result?.caller_path, target);
await verify(client);
} finally {
await client.close();
await transport.close();
}
};
/** Run a real stdio MCP subprocess with an explicitly opened binary target. */
export async function withArtifactMcp(target, verify) {
return withOpenedArtifactMcp(target, "open_binary", verify);
}
/** Run a real stdio MCP subprocess with an explicitly opened bundle resource root. */
export async function withBundleResourcesMcp(target, verify) {
return withOpenedArtifactMcp(target, "open_bundle_resources", verify);
}
/** Require a successful structured MCP tool result rather than its text rendering. */
+31 -5
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from "node:url";
import { firstProcedureAddress } from "../dist/application/RealHopperAssertions.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js";
import { resolveHopperAnalysisProfile } from "../dist/hopper/HopperAnalysisProfile.js";
import { HopperApplicationLauncher } from "../dist/hopper/BridgeLauncher.js";
import { HopperClient } from "../dist/hopper/HopperClient.js";
@@ -26,6 +28,16 @@ const root = await realpath(
await mkdtemp(join(tmpdir(), "rea-hopper-deadlines-")),
);
const target = join(root, "deadline-fixture");
const profileResources = new ArtifactResourceScope();
const profileResourceScope = {
retain: (owner, resource) =>
profileResources.retain({
kind: "lease",
resource,
lease: AnalysisCleanupLease.forCloseOwner(owner, resource),
}),
run: (operation) => profileResources.run(operation),
};
let client;
let primaryFailure;
try {
@@ -39,6 +51,7 @@ try {
launcherPath,
loaderArgsOverride: [],
provider: HOPPER_PROVIDER_IDENTITY,
resourceScope: profileResourceScope,
});
if (!resolved.ok) throw resolved.error;
const profile = resolved.value.profile;
@@ -138,15 +151,28 @@ try {
primaryFailure = cause;
throw cause;
} finally {
let cleanupFailure;
if (client !== undefined) {
const closed = await client.close();
if (!closed.ok)
throw primaryFailure === undefined
? closed.error
if (!closed.ok) cleanupFailure = closed.error;
}
try {
await profileResources.close();
} catch (cause) {
cleanupFailure =
cleanupFailure === undefined
? cause
: new AggregateError(
[primaryFailure, closed.error],
"Hopper verification and cleanup failed",
[cleanupFailure, cause],
"Hopper profile and client cleanup failed",
);
}
await rm(root, { recursive: true, force: true });
if (cleanupFailure !== undefined)
throw primaryFailure === undefined
? cleanupFailure
: new AggregateError(
[primaryFailure, cleanupFailure],
"Hopper verification and cleanup failed",
);
}
+16 -1
View File
@@ -10,6 +10,7 @@ import {
artifactCli,
artifactMcpResult,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
if (process.platform !== "darwin")
@@ -68,6 +69,9 @@ try {
const sha256 = createHash("sha256")
.update(await readFile(join(resources, "Assets.car")))
.digest("hex");
const executableSha256 = createHash("sha256")
.update(await readFile(join(app, "Contents", "MacOS", "Fixture")))
.digest("hex");
const cli = await artifactCli("inspect-asset-catalog", app);
assert.deepEqual(
cli.records.map(({ metadata }) => metadata),
@@ -80,7 +84,7 @@ try {
asset_name === "FixtureColor" && metadata.AssetType === "Color",
),
);
await withArtifactMcp(app, async (client) => {
await withBundleResourcesMcp(app, async (client) => {
assert.deepEqual(
await artifactMcpResult(client, "inspect_asset_catalog"),
cli,
@@ -99,6 +103,17 @@ try {
});
assert.equal(invalid.isError, true);
});
const { resource_root, target_sha256, ...resourcePayload } = cli;
assert.equal(resource_root.executable_sha256, null);
assert.equal(target_sha256, null);
await withArtifactMcp(app, async (client) => {
const binary = await artifactMcpResult(client, "inspect_asset_catalog");
assert.equal(binary.target_sha256, executableSha256);
assert.deepEqual(binary, {
...resourcePayload,
target_sha256: executableSha256,
});
});
report = {
ok: true,
mocked: false,
+3 -1
View File
@@ -30,7 +30,7 @@ const LINK_MODES = [
{
mode: "dyld-info",
flags: ["-Wl,-no_fixup_chains"],
fixups: "LC_DYLD_INFO rebase opcodes were not decoded",
fixups: "LC_DYLD_INFO rebase opcodes",
swift: true,
},
{ mode: "chained", flags: [], fixups: "chained fixups:", swift: true },
@@ -70,6 +70,7 @@ const inspect = async (path) => {
};
const checkObjc = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
const fixture = metadata.objc_classes.find(
({ name, is_meta_class: meta }) => name === "ReaDispatchFixture" && !meta,
);
@@ -159,6 +160,7 @@ const checkObjc = (metadata, mode) => {
};
const checkSwift = (metadata, mode) => {
assert.equal(fixupCoverage(metadata)?.status, "complete");
if (
!metadata.swift_dispatch_slots.some(
(slot) =>
+20 -2
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -12,6 +13,7 @@ import {
artifactCliEvidence,
artifactMcpResult,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
const exec = promisify(execFile);
@@ -49,7 +51,10 @@ try {
appPath,
);
const decoded = evidence.normalized_result;
await withArtifactMcp(appPath, async (client) => {
const executableSha256 = createHash("sha256")
.update(await readFile(executablePath))
.digest("hex");
await withBundleResourcesMcp(appPath, async (client) => {
const mcp = await artifactMcpResult(client, "decode_interface_builder");
assert.deepEqual(
mcp,
@@ -57,6 +62,19 @@ try {
"CLI and MCP compiled archive results differ",
);
});
const { resource_root, target_sha256, ...resourcePayload } = decoded;
assert.equal(resource_root.executable_sha256, null);
assert.equal(target_sha256, null);
await withArtifactMcp(appPath, async (client) => {
const binary = await artifactMcpResult(client, "decode_interface_builder");
assert.equal(binary.target_sha256, executableSha256);
assert.equal(binary.graph.target_sha256, executableSha256);
assert.deepEqual(binary, {
...resourcePayload,
target_sha256: executableSha256,
graph: { ...decoded.graph, target_sha256: executableSha256 },
});
});
assert.equal(decoded.documents.length, 1);
assert.equal(
decoded.documents[0]?.relative_path,
+66 -1
View File
@@ -6,6 +6,7 @@ import { toolContract } from "../../../dist/contracts/toolContracts.js";
import {
artifactCliEvidence,
withArtifactMcp,
withBundleResourcesMcp,
} from "../../lib/artifact-e2e.mjs";
/** Inspect the compiled bundle with real native utilities through both adapters. */
@@ -16,15 +17,79 @@ export async function verifyNativeProvenance(app, executable) {
const operations = [
["inspect_macho", []],
["inspect_signature", []],
["inspect_plist", []],
["list_architectures", []],
["demangle_swift", symbols],
];
const observations = [];
const resourcePlistCli = toolContract("inspect_plist").outputSchema.parse(
await artifactCliEvidence("inspect-plist", app),
);
assert.equal(resourcePlistCli.subject, null);
assert.equal(resourcePlistCli.raw_result, null);
await withBundleResourcesMcp(app, async (client) => {
const response = await client.callTool({
name: "inspect_plist",
arguments: {},
});
assert.notEqual(response.isError, true, JSON.stringify(response));
const mcp = toolContract("inspect_plist").outputSchema.parse(
response.structuredContent,
);
assert.equal(mcp.subject, null);
assert.equal(mcp.raw_result, null);
assert.deepEqual(mcp.normalized_result, resourcePlistCli.normalized_result);
assert.deepEqual(
JSON.parse(response.content.find((item) => item.type === "text").text),
response.structuredContent,
);
await client.ping();
observations.push({
operation: "inspect_plist_resource_root",
commands: resourcePlistCli.normalized_result.provenance.length,
cli_bytes: Buffer.byteLength(JSON.stringify(resourcePlistCli)),
mcp_bytes: Buffer.byteLength(JSON.stringify(response)),
});
});
const binaryPlistCli = toolContract("inspect_plist").outputSchema.parse(
await artifactCliEvidence("inspect-plist", executable),
);
assert.equal(binaryPlistCli.subject?.digest.sha256, sha256);
assert.equal(binaryPlistCli.raw_result, null);
assert.equal(binaryPlistCli.normalized_result.resource_root, undefined);
await withArtifactMcp(app, async (client) => {
const definitions = new Map(
(await client.listTools()).tools.map((tool) => [tool.name, tool]),
);
const plistResponse = await client.callTool(
{ name: "inspect_plist", arguments: {} },
{ toolDefinition: definitions.get("inspect_plist") },
);
assert.notEqual(plistResponse.isError, true, JSON.stringify(plistResponse));
const binaryPlistMcp = toolContract("inspect_plist").outputSchema.parse(
plistResponse.structuredContent,
);
assert.equal(binaryPlistMcp.subject?.digest.sha256, sha256);
assert.equal(binaryPlistMcp.raw_result, null);
assert.equal(binaryPlistMcp.normalized_result.resource_root, undefined);
assert.deepEqual(
binaryPlistMcp.normalized_result,
binaryPlistCli.normalized_result,
);
assert.deepEqual(
JSON.parse(
plistResponse.content.find((item) => item.type === "text").text,
),
plistResponse.structuredContent,
);
await client.ping();
observations.push({
operation: "inspect_plist_binary_bound",
commands: binaryPlistCli.normalized_result.provenance.length,
cli_bytes: Buffer.byteLength(JSON.stringify(binaryPlistCli)),
mcp_bytes: Buffer.byteLength(JSON.stringify(plistResponse)),
});
for (const [operation, arguments_] of operations) {
const cli = toolContract(operation).outputSchema.parse(
await artifactCliEvidence(
+30 -18
View File
@@ -68,7 +68,11 @@ export interface AnalysisExecution {
*/
readonly referencesTruncated?: boolean;
readonly locations: readonly EvidenceLocation[];
readonly subject: EvidenceSubjectTarget | null;
/**
* Missing means the selected operation's default subject should be bound by
* its caller. Explicit null means the observation has no file subject.
*/
readonly subject?: EvidenceSubjectTarget | null;
}
interface CreateAnalysisExecutionOptions {
@@ -79,7 +83,7 @@ interface CreateAnalysisExecutionOptions {
readonly relationshipCoverage?: RelationshipCoverage;
readonly referencesTruncated?: boolean;
readonly locations?: readonly EvidenceLocation[];
readonly subject?: EvidenceSubjectTarget;
readonly subject?: EvidenceSubjectTarget | null;
}
/**
@@ -129,7 +133,7 @@ export function createAnalysisExecution(
? {}
: { referencesTruncated: options.referencesTruncated }),
locations: [...(options.locations ?? [])],
subject: options.subject ?? null,
...(options.subject === undefined ? {} : { subject: options.subject }),
};
}
@@ -143,21 +147,27 @@ export const createAnalysisExecutionEvidence = (
execution: AnalysisExecution,
confidence: "observed" | "derived",
): Evidence =>
createEvidence(execution.subject ?? undefined, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
...(execution.analysisProfile === undefined
? {}
: { analysisProfile: execution.analysisProfile }),
...(execution.relationshipCoverage === undefined
? {}
: { relationshipCoverage: execution.relationshipCoverage }),
locations: execution.locations,
confidence,
});
createEvidence(
execution.subject === undefined
? undefined
: (execution.subject ?? undefined),
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
...(execution.analysisProfile === undefined
? {}
: { analysisProfile: execution.analysisProfile }),
...(execution.relationshipCoverage === undefined
? {}
: { relationshipCoverage: execution.relationshipCoverage }),
locations: execution.locations,
confidence,
},
);
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
@@ -206,6 +216,8 @@ export interface AnalysisClient extends AnalysisOperationPort {
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
/** Report each owned cleanup failure as soon as its owner observes it. */
readonly onCleanupFailure?: (failure: AnalysisError) => void;
},
): Promise<Result<null, AnalysisError>>;
}
+58
View File
@@ -14,6 +14,7 @@ import {
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import { hasBinaryPlistSignature } from "../domain/apple/appleArchiveFormat.js";
import { err, ok, type Result } from "../domain/result.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
/** Decoded representation bound for one binary Info.plist. */
const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024;
@@ -42,6 +43,18 @@ export interface ResolvedAppBundle {
readonly infoPlist?: string;
}
/** Resource-only identity for an app directory; it is not a BinaryTarget. */
export interface ResolvedAppBundleResourceRoot {
/** Path selected by the caller, retained even when it is a symlink alias. */
readonly callerPath: string;
/** Canonical directory whose resources are inspected. */
readonly canonicalPath: string;
readonly infoPlistPath: string;
readonly layout: "contents" | "root";
/** No executable was opened or hashed by resource-root discovery. */
readonly executableSha256: string | null;
}
/** Filesystem operations used by the resolver, injectable for boundary tests. */
export interface AppBundleFileSystem {
readonly readFile: (path: string) => Promise<Buffer>;
@@ -90,6 +103,51 @@ export const resolveAppBundleExecutable = async (
);
};
/**
* Resolve only the root and Info.plist layout needed for read-only resource
* inspection. This intentionally does not require or guess CFBundleExecutable.
*/
export const resolveAppBundleResourceRoot = async (
path: string,
options: { readonly cwd?: string } = {},
): Promise<Result<ResolvedAppBundleResourceRoot, BinaryTargetError>> => {
const callerPath = absoluteCallerPath(
path,
options.cwd === undefined ? {} : { cwd: options.cwd },
);
try {
const canonicalInput = await realpath(callerPath);
const directLayout = await presentLayout(canonicalInput);
const wrapped =
directLayout === undefined
? await wrappedBundle(canonicalInput)
: undefined;
const root = wrapped ?? canonicalInput;
const layout = directLayout ?? (await presentLayout(root));
if (layout === undefined)
return err(
new BinaryTargetError(
callerPath,
`app resource root has no supported Info.plist layout: ${callerPath}`,
),
);
const canonicalPath = await realpath(root);
return ok({
callerPath,
canonicalPath,
infoPlistPath: join(canonicalPath, ...layout.plist),
layout: layout.plist[0] === "Contents" ? "contents" : "root",
executableSha256: null,
});
} catch (cause: unknown) {
return err(
new BinaryTargetError(callerPath, errorReason(cause, callerPath), {
cause,
}),
);
}
};
const presentLayout = async (
bundle: string,
): Promise<BundleLayout | undefined> => {
@@ -0,0 +1,101 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import {
createAnalysisExecution,
createAnalysisExecutionEvidence,
} from "./AnalysisProvider.js";
import { resolveAppBundleResourceRoot } from "./AppBundleExecutable.js";
import { bindExecutionTarget } from "./binary/BinarySessionExecution.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
const provider = { id: "test", name: "test", version: null } as const;
const target = {
path: "/Fixture.app/Contents/Info.plist",
sha256: "a".repeat(64),
kind: "artifact",
format: "plist",
} satisfies BinaryTarget;
it("resolves resource roots without requiring or hashing CFBundleExecutable", async () => {
const temp = await mkdtemp(join(tmpdir(), "rea-app-resource-root-"));
try {
const bundle = join(temp, "Fixture.app");
const contents = join(bundle, "Contents");
const alias = join(temp, "Selected.app");
await mkdir(contents, { recursive: true });
await writeFile(join(contents, "Info.plist"), "<plist><dict/></plist>");
await symlink(bundle, alias);
const resolved = await resolveAppBundleResourceRoot(alias);
expect(resolved).toEqual({
ok: true,
value: {
callerPath: alias,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Contents", "Info.plist"),
layout: "contents",
executableSha256: null,
},
});
} finally {
await rm(temp, { recursive: true, force: true });
}
});
it("resolves a flat bundle resource root without inferring executable identity", async () => {
const temp = await mkdtemp(join(tmpdir(), "rea-ios-resource-root-"));
try {
const bundle = join(temp, "Fixture.app");
await mkdir(bundle);
await writeFile(join(bundle, "Info.plist"), "<plist><dict/></plist>");
const resolved = await resolveAppBundleResourceRoot(bundle);
expect(resolved).toMatchObject({
ok: true,
value: {
callerPath: bundle,
canonicalPath: bundle,
infoPlistPath: join(bundle, "Info.plist"),
layout: "root",
executableSha256: null,
},
});
} finally {
await rm(temp, { recursive: true, force: true });
}
});
it("distinguishes default target binding from an explicitly unbound observation", () => {
const defaultSubject = createAnalysisExecution({ ok: true }, provider);
const unbound = createAnalysisExecution({ ok: true }, provider, {
subject: null,
});
const bound = bindExecutionTarget(
{ ok: true, value: defaultSubject },
"inspect_plist",
target,
);
const preserved = bindExecutionTarget(
{ ok: true, value: unbound },
"inspect_plist",
target,
);
expect(bound.ok && bound.value.subject).toEqual({
path: target.path,
sha256: target.sha256,
format: "plist",
});
expect(preserved.ok && preserved.value.subject).toBeNull();
expect(
createAnalysisExecutionEvidence("inspect_plist", {}, unbound, "observed")
.subject,
).toBeNull();
});
+1 -1
View File
@@ -478,7 +478,7 @@ const workflowAvailabilityFor = ({
: {
reason: "provider_missing",
remediation:
"On Linux, supply TShark 4.x via absolute REA_TSHARK_COMMAND and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
"On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.",
};
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
return policy.signalRecordingEnabled === true
@@ -74,7 +74,10 @@ export const retryClientConfigurationCleanup = async (
if (retried.cleanup !== undefined) observations.push(retried.cleanup);
primary = retried.primary;
const last = retried.failures.at(-1)!;
nested = last.outcome.kind === "failed" ? last.outcome.cause : undefined;
nested =
last.operationFailure === undefined && last.outcome.kind === "failed"
? last.outcome.cause
: undefined;
} else {
const released = await resources.release({
kind: "lease",
+42 -4
View File
@@ -24,10 +24,14 @@ import {
} from "../domain/evidenceErrors.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { access } from "node:fs/promises";
import { access, stat } from "node:fs/promises";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js";
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
import {
classifyBinaryTargetFilesystemFailure,
parseBinaryTarget,
} from "./BinaryTargetResolver.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
snapshotEvidenceForQuery,
@@ -56,11 +60,13 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { resolveXrefsAddress } from "./XrefsAddressResolution.js";
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
import type { BinaryTargetError } from "../domain/configurationErrors.js";
import {
directAnalysisCleanupFailure,
managedAnalysisCleanupFailure,
withSessionCleanup,
} from "./DirectAnalysisCleanup.js";
import { BUNDLE_RESOURCE_OPERATIONS } from "./binary/BundleResourceSession.js";
type DirectAnalysisTool =
| "annotate_native_function"
@@ -97,7 +103,6 @@ const NATIVE_INSPECTION_OPERATIONS: ReadonlySet<string> = new Set([
const ARTIFACT_ANALYSIS_OPERATION_SET: ReadonlySet<string> = new Set(
ARTIFACT_ANALYSIS_OPERATIONS,
);
/**
* Open one binary, execute one tool, and always release provider resources.
* Unlike MCP mode, every CLI invocation is intentionally isolated and does not
@@ -249,6 +254,24 @@ const runAnalysis = async (
return withSessionCleanup(
session,
async () => {
let isDirectory = false;
if (BUNDLE_RESOURCE_OPERATIONS.has(tool)) {
const inspected = await inspectBundleResourceInput(path);
if (!inspected.ok) return cliError(inspected.error);
isDirectory = inspected.value;
}
if (BUNDLE_RESOURCE_OPERATIONS.has(tool) && isDirectory) {
const opened = await session.openBundleResources(path, { signal });
if (!opened.ok) return cliError(opened.error);
const execution = await session.execute(tool, arguments_, { signal });
if (!execution.ok) return cliError(execution.error);
return createAnalysisExecutionEvidence(
tool,
arguments_,
execution.value,
"observed",
);
}
const prepared = await prepareSnapshot({
path,
snapshotPath,
@@ -493,7 +516,9 @@ const executeAnalysisTool = async (input: {
}
if (!result.ok) return { output: cliError(result.error) };
const evidence = createEvidence(
result.value.subject ?? input.openedTarget,
result.value.subject === undefined
? input.openedTarget
: (result.value.subject ?? undefined),
result.value.provider,
{
operation: tool,
@@ -577,6 +602,19 @@ const prepareSnapshot = async (options: {
return ok({ snapshot: loaded.value, target: target.value });
};
const inspectBundleResourceInput = async (
path: string,
): Promise<Result<boolean, BinaryTargetError>> => {
const callerPath = absoluteCallerPath(path);
try {
return ok((await stat(callerPath)).isDirectory());
} catch (cause: unknown) {
const failure = classifyBinaryTargetFilesystemFailure(callerPath, cause);
if (failure !== undefined) return err(failure);
throw cause;
}
};
const allowsSnapshotReplay = (
route: SessionProviderRoute,
tool:
@@ -0,0 +1,117 @@
import { afterEach, expect, it, vi } from "vitest";
const statFailures = vi.hoisted(() => new Map<string, unknown>());
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
stat: vi.fn(async (...args: Parameters<typeof original.stat>) => {
const failure = statFailures.get(String(args[0]));
if (failure !== undefined) throw failure;
return original.stat(...args);
}),
};
});
import { parseConfig } from "../config/parseConfig.js";
import { classifyBinaryTargetFilesystemFailure } from "./BinaryTargetResolver.js";
import type { BinarySession } from "./binary/BinarySession.js";
import type { DirectAnalysisDependencies } from "./DirectAnalysisDependencies.js";
import { runProviderAnalysis } from "./DirectAnalysis.js";
afterEach(() => statFailures.clear());
it("retains the original filesystem cause on the shared typed error", () => {
const cause = Object.assign(new Error("permission denied"), {
code: "EACCES",
});
const failure = classifyBinaryTargetFilesystemFailure(
"/tmp/rea-stat-failure-cause.app",
cause,
);
expect(failure?.cause).toBe(cause);
expect(failure?.systemCode).toBe("EACCES");
});
it.each([
{ code: "ENOENT", projectedCode: "target_unavailable" },
{ code: "ENOTDIR", projectedCode: "target_unavailable" },
{ code: "EACCES", projectedCode: "access_denied", systemCode: "EACCES" },
{ code: "EPERM", projectedCode: "access_denied", systemCode: "EPERM" },
{ code: "EIO", projectedCode: "target_unavailable" },
])(
"stops CLI resource dispatch on stat failure $code and preserves its path",
async ({ code, projectedCode, systemCode }) => {
const path = `/tmp/rea-stat-failure-${code}.app`;
const cause = Object.assign(new Error(`${code}: injected stat failure`), {
code,
});
statFailures.set(path, cause);
const session = {
close: vi.fn(async () => ({ ok: true as const, value: null })),
open: vi.fn(),
openBundleResources: vi.fn(),
execute: vi.fn(),
} as unknown as BinarySession;
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
const output = await runProviderAnalysis(
dependencies,
path,
"inspect_keyed_archive",
{ path: "Resources/Archive.plist" },
);
expect(output).toMatchObject({
code: projectedCode,
details: {
path,
reason: expect.stringContaining(cause.message),
...(systemCode === undefined ? {} : { system_code: systemCode }),
},
});
expect(session.open).not.toHaveBeenCalled();
expect(session.openBundleResources).not.toHaveBeenCalled();
expect(session.execute).not.toHaveBeenCalled();
expect(session.close).toHaveBeenCalledOnce();
},
);
it("rethrows an unclassified stat failure instead of treating it as a file", async () => {
const path = "/tmp/rea-stat-failure-opaque.app";
const cause = new Error("opaque filesystem failure");
statFailures.set(path, cause);
const session = {
close: vi.fn(async () => ({ ok: true as const, value: null })),
open: vi.fn(),
openBundleResources: vi.fn(),
execute: vi.fn(),
} as unknown as BinarySession;
const dependencies: DirectAnalysisDependencies = {
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
createNativeDispatchBinarySession: () => session,
createArtifactBinarySession: () => session,
createNativeBinarySession: () => session,
createManagedBinarySession: () => session,
};
await expect(
runProviderAnalysis(dependencies, path, "inspect_keyed_archive", {
path: "Resources/Archive.plist",
}),
).rejects.toBe(cause);
expect(session.open).not.toHaveBeenCalled();
expect(session.openBundleResources).not.toHaveBeenCalled();
expect(session.execute).not.toHaveBeenCalled();
expect(session.close).toHaveBeenCalledOnce();
});
@@ -0,0 +1,446 @@
import { describe, expect, it, vi } from "vitest";
import {
createAnalysisExecution,
type AnalysisOperation,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { EnhancedTools } from "./EnhancedTools.js";
import type { BinaryArchitecture } from "../domain/binaryTargetTypes.js";
import { objcSwiftMetadataSchema } from "../domain/native/objcSwiftMetadata.js";
import { err, ok } from "../domain/result.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import {
AnalysisOutputError,
AnalysisCancelledError,
} from "../domain/analysisErrorCore.js";
import { createAnalysisProfile } from "../domain/analysisProfile.js";
import type { MachoTargetSelection } from "../domain/apple/machoTargetSelection.js";
const sha256 = "a".repeat(64);
const provider = { id: "native", name: "Native", version: "1" };
const namesProvider = { id: "hopper", name: "Hopper", version: "2" };
const selection = {
kind: "macho-selected-image-v1" as const,
container: "thin" as const,
offset: 0,
byte_length: 4096,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: null,
};
const profile = createAnalysisProfile(namesProvider, {
target_selection: selection,
});
const subject = {
sha256,
path: "/fixture",
format: "mach-o" as const,
architecture: "arm64" as const,
};
const createPort = (
nativeOverrides: Partial<{
sha256: string;
architecture: BinaryArchitecture;
names: Array<{ address: string; value: string }>;
nameLimitations: string[];
metadataFailure: AnalysisError;
bindings: Array<{
requested_address: string;
symbol: string;
binding_source: "chained" | "bind" | "weak" | "lazy";
library_ordinal: number;
library: string | null;
weak: boolean;
addend: string;
}>;
targetSelection: MachoTargetSelection | null;
nameProfile: boolean;
}> = {},
): AnalysisOperationPort => ({
execute: async (operation: AnalysisOperation, parameters) => {
if (operation === "list_names")
return ok(
createAnalysisExecution(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
],
namesProvider,
{
subject,
limitations: nativeOverrides.nameLimitations ?? [],
...(nativeOverrides.nameProfile === false
? {}
: { analysisProfile: profile }),
},
),
);
if (operation !== "inspect_native_dispatch_metadata")
return err(
new AnalysisCapabilityUnavailableError(
provider.id,
operation,
"Unavailable in fixture",
),
);
expect(parameters).toMatchObject({
max_records: 20_000,
objc_class_import_addresses: [
...new Set(
(
nativeOverrides.names ?? [
{ address: "0x1000", value: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", value: "OBJC_CLASS_$_Document" },
]
).map(({ address }) => address),
),
],
objc_class_import_addresses_complete: true,
});
if (nativeOverrides.metadataFailure !== undefined)
return err(nativeOverrides.metadataFailure);
const result = objcSwiftMetadataSchema.parse({ db_save_result: null });
if (nativeOverrides.targetSelection !== null)
result.target_selection = nativeOverrides.targetSelection ?? selection;
result.objc_class_import_bindings = nativeOverrides.bindings ?? [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "complete",
reason: null,
examined: 1,
decoded: result.objc_class_import_bindings.length,
};
return ok(
createAnalysisExecution(
{
target_sha256: nativeOverrides.sha256 ?? sha256,
target_architecture: nativeOverrides.architecture ?? "arm64",
provider,
analysis_profile_digest: null,
result,
},
provider,
{
subject: {
...subject,
...(nativeOverrides.sha256 === undefined
? {}
: { sha256: nativeOverrides.sha256 }),
...(nativeOverrides.architecture === undefined
? {}
: { architecture: nativeOverrides.architecture }),
},
},
),
);
},
});
it.each([
["list_names", "omitted"],
["list_names", "unbound"],
["inspect_native_dispatch_metadata", "omitted"],
["inspect_native_dispatch_metadata", "unbound"],
] as const)(
"retains class candidates when %s subject is %s",
async (operation, state) => {
const port = createPort();
const tools = new EnhancedTools({
execute: async (...args) => {
const result = await port.execute(...args);
if (!result.ok || args[0] !== operation) return result;
const value = { ...result.value };
if (state === "omitted") delete value.subject;
else value.subject = null;
return ok(value);
},
});
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
},
);
describe("Objective-C class import identity composition", () => {
it("joins only exact same-target bind slots and preserves literal filter selection", async () => {
const tools = new EnhancedTools(createPort());
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [
{
address: "0x1000",
name: "OBJC_CLASS_$_Document",
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
slot_addresses: ["0x1000"],
},
},
],
identity_coverage: { status: "complete", reason: null },
},
});
await expect(
tools.execute("get_objc_classes", { pattern: "$GOT" }),
).resolves.toMatchObject({
ok: true,
value: {
count: 1,
classes: [{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" }],
},
});
});
it("keeps rows unjoined when the native observation has a different subject", async () => {
const tools = new EnhancedTools(createPort({ sha256: "b".repeat(64) }));
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
classes: [
{ address: "0x1000", name: "OBJC_CLASS_$_Document$GOT" },
{ address: "0x1000", name: "OBJC_CLASS_$_Document" },
],
identity_coverage: { status: "unknown" },
},
});
});
it("keeps identity unknown when exact selected-image evidence is missing or mismatched", async () => {
const cases = [
{ nameProfile: false },
{ targetSelection: null },
{ targetSelection: { ...selection, cpu_type: 0x01000007 } },
{ targetSelection: { ...selection, cpu_subtype: 2 } },
{
targetSelection: {
...selection,
container: "fat" as const,
offset: 4096,
container_table_sha256: "b".repeat(64),
},
},
];
for (const overrides of cases) {
const tools = new EnhancedTools(createPort(overrides));
await expect(
tools.execute("get_objc_classes", {}),
).resolves.toMatchObject({
ok: true,
value: { count: 2, identity_coverage: { status: "unknown" } },
});
}
});
it("marks identity coverage partial when the selected names inventory is limited", async () => {
const tools = new EnhancedTools(
createPort({ nameLimitations: ["provider capped the name inventory"] }),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 1,
identity_coverage: {
status: "partial",
reason: "list_names: provider capped the name inventory",
},
},
});
});
it("keeps names with unknown identity when binding inspection is unavailable", async () => {
const tools = new EnhancedTools(
createPort({
metadataFailure: new AnalysisCapabilityUnavailableError(
provider.id,
"inspect_native_dispatch_metadata",
"No static Mach-O reader",
),
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 2,
identity_coverage: { status: "unknown" },
},
});
});
it("preserves non-capability binding failures", async () => {
const failure = new AnalysisOutputError(
"inspect_native_dispatch_metadata",
"fixture failed",
);
const tools = new EnhancedTools(createPort({ metadataFailure: failure }));
await expect(tools.execute("get_objc_classes", {})).resolves.toEqual({
ok: false,
error: failure,
});
});
});
it("preserves same-named rows whose exact import slots have different identities", async () => {
const addresses = ["0x1000", "0x2000", "0x3000", "0x4000"];
const name = "OBJC_CLASS_$_Document";
const tools = new EnhancedTools(
createPort({
names: [
...addresses.map((address) => ({ address, value: name })),
{ address: "0x1000", value: name },
],
bindings: [
{
requested_address: "0x1000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "0",
},
{
requested_address: "0x2000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/UIKit.framework/UIKit",
weak: false,
addend: "0",
},
{
requested_address: "0x3000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: true,
addend: "0",
},
{
requested_address: "0x4000",
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: false,
addend: "1",
},
],
}),
);
await expect(tools.execute("get_objc_classes", {})).resolves.toMatchObject({
ok: true,
value: {
count: 4,
classes: addresses.map((address, index) => ({
address,
name,
binding_identity: {
symbol: "_OBJC_CLASS_$_Document",
binding_source: "chained",
library_ordinal: 1,
library:
index === 1
? "/System/Library/Frameworks/UIKit.framework/UIKit"
: "/System/Library/Frameworks/AppKit.framework/AppKit",
weak: index === 2,
addend: index === 3 ? "1" : "0",
slot_addresses: [address],
},
})),
identity_coverage: { status: "complete", reason: null },
},
});
});
describe("EnhancedTools cancellation ownership", () => {
it("preserves the caller reason at admission", async () => {
const reason = { request: "cancel-before-observation" };
const controller = new AbortController();
controller.abort(reason);
const execute = vi.fn(createPort().execute);
const tools = new EnhancedTools({ execute });
const result = await tools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Pre-cancelled observation was accepted");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(execute).not.toHaveBeenCalled();
});
it("preserves a post-success caller reason without replacing provider failures", async () => {
const reason = { request: "cancel-after-provider-success" };
const controller = new AbortController();
const successfulPort = createPort();
const successTools = new EnhancedTools({
execute: async (...args) => {
const result = await successfulPort.execute(...args);
if (args[0] === "list_names" && result.ok) controller.abort(reason);
return result;
},
});
const cancelled = await successTools.execute(
"get_objc_classes",
{},
controller.signal,
);
expect(cancelled.ok).toBe(false);
if (cancelled.ok)
throw new Error("Post-cancelled observation was accepted");
expect(cancelled.error).toBeInstanceOf(AnalysisCancelledError);
expect(cancelled.error.cause).toBe(reason);
const physicalFailure = new AnalysisOutputError(
"list_names",
"fixture read failed",
);
const failingController = new AbortController();
const failureTools = new EnhancedTools({
execute: async (...args) => {
if (args[0] === "list_names") {
failingController.abort(reason);
return err(physicalFailure);
}
return successfulPort.execute(...args);
},
});
const failed = await failureTools.execute(
"get_objc_classes",
{},
failingController.signal,
);
expect(failed).toEqual({ ok: false, error: physicalFailure });
});
});
+147 -7
View File
@@ -9,6 +9,7 @@ import type {
} from "./AnalysisProvider.js";
import {
enhancedInputSchemas,
MAX_ENHANCED_RECORDS,
type CallRelationshipDirection,
type EnhancedToolName,
} from "../contracts/enhancedInputs.js";
@@ -30,15 +31,24 @@ import {
import { err, ok, type Result } from "../domain/result.js";
import {
categorizeSwiftTypes,
discoverObjcClasses,
discoverObjcProtocols,
projectObjcClassCandidates,
selectObjcClassCandidates,
} from "../domain/symbolAnalysis.js";
import { inspectNativeDispatch } from "./native/NativeDispatchMetadataInspection.js";
import { traceNativeValues } from "./native/NativeValueTrace.js";
import { traceNativeUiAction } from "./native/NativeUiActionTrace.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
import { nativeDispatchMetadataResultSchema } from "../domain/native/objcSwiftMetadata.js";
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import {
machoTargetSelectionMatchesArchitecture,
machoTargetSelectionMatchesFormat,
profileMachoTargetSelection,
sameMachoTargetSelection,
} from "../domain/apple/machoTargetSelection.js";
import {
invalidEnhancedInput,
@@ -187,6 +197,8 @@ export class EnhancedTools {
return this.#inspectNativeDispatchMetadata(
call.input.max_records,
signal,
call.input.objc_class_import_addresses,
call.input.objc_class_import_addresses_complete,
);
case "get_objc_classes":
return this.#objcClasses(call.input.pattern, signal);
@@ -286,15 +298,137 @@ export class EnhancedTools {
}
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
const namesExecution = await this.#observe("list_names", {}, signal);
if (!namesExecution.ok) return namesExecution;
const names = this.#parseAddressed(
"list_names",
namesExecution.value.result,
);
if (!names.ok) return names;
const candidates = selectObjcClassCandidates(names.value, pattern);
if (candidates.length === 0)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: { status: "not_applicable", reason: null },
}),
);
const addresses = [...new Set(candidates.map(({ address }) => address))];
const scannedAddresses = addresses.slice(0, MAX_ENHANCED_RECORDS);
const bindingsExecution = await this.#observe(
"inspect_native_dispatch_metadata",
{
max_records: MAX_ENHANCED_RECORDS,
objc_class_import_addresses: scannedAddresses,
objc_class_import_addresses_complete:
addresses.length <= MAX_ENHANCED_RECORDS,
},
signal,
);
if (!bindingsExecution.ok) {
if (bindingsExecution.error._tag === "AnalysisCancelledError")
return bindingsExecution;
if (bindingsExecution.error._tag !== "AnalysisCapabilityUnavailableError")
return bindingsExecution;
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason: `Native class-import binding evidence is unavailable (${bindingsExecution.error._tag}).`,
},
}),
);
}
const native = nativeDispatchMetadataResultSchema.safeParse(
bindingsExecution.value.result,
);
const nameSubject = namesExecution.value.subject;
const bindingSubject = bindingsExecution.value.subject;
const namesSelection = profileMachoTargetSelection(
namesExecution.value.analysisProfile,
);
const nativeSelection = native.success
? native.data.result.target_selection
: undefined;
if (
!native.success ||
nameSubject === null ||
nameSubject === undefined ||
bindingSubject === null ||
bindingSubject === undefined ||
nameSubject.sha256 !== bindingSubject.sha256 ||
nameSubject.architecture === undefined ||
bindingSubject.architecture === undefined ||
nameSubject.architecture !== bindingSubject.architecture ||
!isMachOFormat(nameSubject.format) ||
nameSubject.format !== bindingSubject.format ||
native.data.target_sha256 !== nameSubject.sha256 ||
native.data.target_architecture !== nameSubject.architecture ||
native.data.provider.id !== bindingsExecution.value.provider.id ||
namesExecution.value.analysisProfile?.provider.id !==
namesExecution.value.provider.id ||
nativeSelection === undefined ||
!machoTargetSelectionMatchesArchitecture(
nativeSelection,
nameSubject.architecture,
) ||
!machoTargetSelectionMatchesFormat(nativeSelection, nameSubject.format) ||
!sameMachoTargetSelection(namesSelection, nativeSelection) ||
native.data.result.objc_class_import_bindings === undefined ||
native.data.result.objc_class_import_binding_coverage === undefined
)
return ok(
projectObjcClassCandidates(candidates, {
bindings: [],
coverage: {
status: "unknown",
reason:
"Native class-import facts did not prove matching artifact, architecture, and complete producer identity.",
},
}),
);
const coverage = native.data.result.objc_class_import_binding_coverage;
const inventoryLimitations = namesExecution.value.limitations;
const inventoryIncomplete = inventoryLimitations.length > 0;
const coverageReason = [
coverage.reason,
...inventoryLimitations.map((limitation) => `list_names: ${limitation}`),
]
.filter((reason): reason is string => reason !== null)
.join("; ");
return ok(
projectObjcClassCandidates(candidates, {
bindings: native.data.result.objc_class_import_bindings,
coverage: {
status:
coverage.status === "not_requested"
? "unknown"
: coverage.status === "complete" && inventoryIncomplete
? "partial"
: coverage.status,
reason: coverageReason.length === 0 ? null : coverageReason,
},
}),
);
}
#inspectNativeDispatchMetadata(
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult {
return inspectNativeDispatch(this.analysis, maxRecords, signal);
return inspectNativeDispatch(
this.analysis,
maxRecords,
signal,
objcClassImportAddresses,
objcClassImportAddressesComplete,
);
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
@@ -677,7 +811,11 @@ export class EnhancedTools {
) {
const result = await this.#call(tool, {}, signal);
if (!result.ok) return result;
const parsed = z.array(addressedValueSchema).safeParse(result.value);
return this.#parseAddressed(tool, result.value);
}
#parseAddressed(tool: "list_names" | "list_procedures", value: JsonValue) {
const parsed = z.array(addressedValueSchema).safeParse(value);
return parsed.success
? ok(
parsed.data.map(({ address, value: name, objc_class_label }) => ({
@@ -715,13 +853,15 @@ export class EnhancedTools {
signal?: AbortSignal,
selectorOrigin?: ExecutionOptions["selectorOrigin"],
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
const execution = await this.analysis.execute(name, arguments_, {
...(signal === undefined ? {} : { signal }),
...(selectorOrigin === undefined ? {} : { selectorOrigin }),
});
if (!execution.ok) return execution;
if (isAborted(signal)) return err(new AnalysisCancelledError(name));
if (isAborted(signal))
return err(new AnalysisCancelledError(name, { cause: signal?.reason }));
return execution;
}
}
+20 -17
View File
@@ -22,7 +22,6 @@ import {
canonicalJsonParts,
} from "../domain/jsonSerialization.js";
import { readJsonFile, writeTextParts } from "./JsonFiles.js";
import { RegularFileCleanupFailure } from "./RegularFileRead.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
type EvidenceReadFailure =
@@ -58,23 +57,27 @@ const readBundle = async (
resources?: ArtifactResourceScope,
): Promise<Result<EvidenceBundle, EvidenceReadFailure>> => {
if (signal?.aborted)
return err(new AnalysisCancelledError("import_evidence_bundle"));
const loaded = await readJsonFile(path, signal, resources).catch(
(cause: unknown) => {
// A failed close transfers a live handle owner; preserve that failure.
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
return err(
new AnalysisCancelledError("import_evidence_bundle", { cause }),
);
throw cause;
},
);
if (!loaded.ok) return loaded;
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources);
if (!loaded.ok) {
if (loaded.error instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: loaded.error.cause,
}),
);
return loaded;
}
if (signal?.aborted)
return err(new AnalysisCancelledError("import_evidence_bundle"));
return err(
new AnalysisCancelledError("import_evidence_bundle", {
cause: signal.reason,
}),
);
let bundle: EvidenceBundle;
try {
bundle = parseBundle(loaded.value);
+20 -14
View File
@@ -39,19 +39,25 @@ export const executeFunctionAnalysisEvidence = async (
if (!dossier.ok) return dossier;
const observation = execution.value;
return ok(
createEvidence(observation.subject ?? target, observation.provider, {
operation: "analyze_function",
parameters: arguments_,
result: dossier.value,
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
...(observation.relationshipCoverage === undefined
? {}
: { relationshipCoverage: observation.relationshipCoverage }),
...(observation.analysisProfile === undefined
? {}
: { analysisProfile: observation.analysisProfile }),
}),
createEvidence(
observation.subject === undefined
? target
: (observation.subject ?? undefined),
observation.provider,
{
operation: "analyze_function",
parameters: arguments_,
result: dossier.value,
rawResult: observation.rawResult,
limitations: observation.limitations,
locations: observation.locations,
...(observation.relationshipCoverage === undefined
? {}
: { relationshipCoverage: observation.relationshipCoverage }),
...(observation.analysisProfile === undefined
? {}
: { analysisProfile: observation.analysisProfile }),
},
),
);
};
+44 -3
View File
@@ -19,6 +19,8 @@ import {
} from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import {
JsonInputPhysicalReadFailure,
JsonInputCancellationFailure,
readJsonInputFile,
readJsonObjectFile,
type JsonObjectMemberReader,
@@ -46,7 +48,10 @@ export const readJsonFile = async (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<
Result<unknown, EvidenceFileError | AnalysisResourceConstraintError>
Result<
unknown,
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
>
> =>
readJsonFileInput(
path,
@@ -93,7 +98,10 @@ const readJsonFileInput = async <Value>(
>,
signal?: AbortSignal,
): Promise<
Result<Value, EvidenceFileError | AnalysisResourceConstraintError>
Result<
Value,
EvidenceFileError | AnalysisResourceConstraintError | AnalysisCancelledError
>
> => {
const requestedPath = absoluteCallerPath(path);
try {
@@ -116,7 +124,25 @@ const readJsonFileInput = async <Value>(
} catch (cause: unknown) {
if (cause instanceof RegularFileCleanupFailure) throw cause;
if (cause instanceof AnalysisResourceConstraintError) return err(cause);
if (signal?.aborted === true) throw cause;
if (cause instanceof JsonInputCancellationFailure)
return err(
new AnalysisCancelledError("read_evidence_file", {
cause: cause.reason,
}),
);
if (cause instanceof JsonInputPhysicalReadFailure)
return err(
new EvidenceFileError("read", "io", {
cause: cause.physicalCause,
path: requestedPath,
}),
);
if (isJsonReadCancellation(cause, signal))
return err(
new AnalysisCancelledError("read_evidence_file", {
cause: signal?.reason,
}),
);
return err(
new EvidenceFileError(
"read",
@@ -132,6 +158,21 @@ const readJsonFileInput = async <Value>(
}
};
const isJsonReadCancellation = (
cause: unknown,
signal?: AbortSignal,
): boolean => {
if (signal?.aborted !== true) return false;
if (cause === signal.reason) return true;
return (
cause instanceof Error &&
cause.name === "AbortError" &&
"code" in cause &&
cause.code === "ABORT_ERR" &&
cause.cause === signal.reason
);
};
/** Atomically write text to the caller-supplied path. */
export const writeTextFile = async (
encoded: string,
+223 -107
View File
@@ -1,5 +1,6 @@
import { constants } from "node:buffer";
import { createHash } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { getHeapStatistics } from "node:v8";
@@ -10,10 +11,17 @@ import parser from "stream-json/parser.js";
import streamValues from "stream-json/streamers/stream-values.js";
import streamArray from "stream-json/streamers/stream-array.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import {
assertRegularFileActive,
RegularFileChangedError,
} from "../filesystem/RegularFile.js";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { sealOwnedJsonInput } from "./OwnedJsonInput.js";
import { withRegularFile } from "./RegularFileRead.js";
import {
RegularFileReadFailure,
withRegularFile,
type RegularFileReadOptions,
} from "./RegularFileRead.js";
import {
JSON_BYTE_ORDER_MARK_MESSAGE,
JSON_INPUT_RESOURCE_REMEDIATION,
@@ -34,6 +42,74 @@ const PACKED_TOKEN_PART_CODE_UNITS = 64 * 1024;
// This selects an implementation; larger files remain accepted through streaming.
const NATIVE_PARSE_READ_BUDGET = 8 * 1024 * 1024;
/** Physical descriptor failure remains primary if cancellation arrives afterward. */
export class JsonInputPhysicalReadFailure extends Error {
readonly _tag = "JsonInputPhysicalReadFailure";
readonly physicalCause: unknown;
constructor(physicalCause: unknown) {
super("JSON input descriptor read failed");
this.physicalCause = physicalCause;
}
}
/** Explicit cancellation fact; the caller's value is opaque and retained as data. */
export class JsonInputCancellationFailure extends Error {
readonly _tag = "JsonInputCancellationFailure";
constructor(readonly reason: unknown) {
super(
reason instanceof Error ? reason.message : "JSON input read cancelled",
);
}
}
interface JsonInputPhysicalReadObservation {
failure?: JsonInputPhysicalReadFailure;
}
const readPhysicalJsonInput = async <Value>(
observation: JsonInputPhysicalReadObservation,
read: () => Promise<Value>,
): Promise<Value> => {
try {
return await read();
} catch (physicalCause: unknown) {
const failure = new JsonInputPhysicalReadFailure(physicalCause);
observation.failure = failure;
throw failure;
}
};
const withJsonInputFile = <Value>(
path: string,
read: (handle: FileHandle, stats: Stats) => Promise<Value>,
options: Pick<RegularFileReadOptions, "signal" | "resources">,
): Promise<Value> =>
withRegularFile(path, read, {
...options,
cancellationFailure: (reason) => new JsonInputCancellationFailure(reason),
}).catch((cause: unknown) => {
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
throw cause;
});
const withJsonInputPipeline = async <Value>(
signal: AbortSignal | undefined,
run: (signal?: AbortSignal) => Promise<Value>,
): Promise<Value> => {
if (signal === undefined) return run();
const controller = new AbortController();
const cancel = (): void => {
controller.abort(new JsonInputCancellationFailure(signal.reason));
};
if (signal.aborted) cancel();
else signal.addEventListener("abort", cancel, { once: true });
try {
return await run(controller.signal);
} finally {
signal.removeEventListener("abort", cancel);
}
};
/** Heap a whole-file native JSON.parse may need: the assembler's 64x container expansion plus byte and text copies. */
export const nativeJsonParseRequiredBytes = (
inputBytes: number,
@@ -66,10 +142,11 @@ export const readJsonObjectFile = (
signal?: AbortSignal,
resources?: ArtifactResourceScope,
): Promise<Result<void, JsonInputFileFailure>> =>
withRegularFile(
withJsonInputFile(
path,
async (handle, stats) => {
let previousDigest: string | undefined;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
@@ -77,6 +154,8 @@ export const readJsonObjectFile = (
path,
operation,
signal,
false,
physicalRead,
);
if (native !== undefined) {
if (!native.ok) return native;
@@ -92,7 +171,7 @@ export const readJsonObjectFile = (
for (const members of scans)
for (const [key, member] of members) {
if (!Object.hasOwn(value, key)) continue;
signal?.throwIfAborted();
assertRegularFileActive(signal);
member.begin();
const selected: unknown = Reflect.get(value, key);
if (member.mode === "array" && !Array.isArray(selected)) {
@@ -105,7 +184,7 @@ export const readJsonObjectFile = (
Array.isArray(selected)
? selected
: [selected]) {
signal?.throwIfAborted();
assertRegularFileActive(signal);
try {
await member.accept(item);
} catch (cause: unknown) {
@@ -123,68 +202,80 @@ export const readJsonObjectFile = (
let consume:
| ((token: parser.Token) => typeof none | Promise<typeof none>)
| undefined;
await pipeline(
decodedChunks(handle, stats.size, path, signal, (bytes) => {
hash.update(bytes);
}),
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
(token: parser.Token): typeof none | Promise<typeof none> => {
const opens =
token.name === "startObject" || token.name === "startArray";
const closes =
token.name === "endObject" || token.name === "endArray";
if (depth === 0) {
rootObject = token.name === "startObject";
} else if (
rootObject &&
depth === 1 &&
token.name === "keyValue"
) {
key = token.value;
} else if (rootObject && depth === 1 && !closes) {
const member =
key === undefined ? undefined : members.get(key);
consume = undefined;
if (member !== undefined) {
member.begin();
if (
member.mode === "array" &&
token.name !== "startArray"
) {
member.reject(
new TypeError(`JSON member ${key} must be an array`),
);
} else {
consume = memberConsumer(
member,
operation,
stats.size,
path,
);
}
}
key = undefined;
}
const selected = consume;
if (opens) depth += 1;
if (closes) depth -= 1;
if (rootObject && depth === 1 && token.name !== "keyValue")
consume = undefined;
if (selected === undefined) return none;
return selected(token);
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
(bytes) => {
hash.update(bytes);
},
physicalRead,
),
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
(token: parser.Token): typeof none | Promise<typeof none> => {
const opens =
token.name === "startObject" ||
token.name === "startArray";
const closes =
token.name === "endObject" || token.name === "endArray";
if (depth === 0) {
rootObject = token.name === "startObject";
} else if (
rootObject &&
depth === 1 &&
token.name === "keyValue"
) {
key = token.value;
} else if (rootObject && depth === 1 && !closes) {
const member =
key === undefined ? undefined : members.get(key);
consume = undefined;
if (member !== undefined) {
member.begin();
if (
member.mode === "array" &&
token.name !== "startArray"
) {
member.reject(
new TypeError(
`JSON member ${key} must be an array`,
),
);
} else {
consume = memberConsumer(
member,
operation,
stats.size,
path,
);
}
}
key = undefined;
}
const selected = consume;
if (opens) depth += 1;
if (closes) depth -= 1;
if (rootObject && depth === 1 && token.name !== "keyValue")
consume = undefined;
if (selected === undefined) return none;
return selected(token);
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
signal === undefined ? {} : { signal },
);
if (!rootObject)
return err(
@@ -197,6 +288,7 @@ export const readJsonObjectFile = (
}
return ok(undefined);
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
@@ -256,11 +348,12 @@ export const readJsonInputFile = (
resources?: ArtifactResourceScope,
snapshot = false,
): Promise<Result<unknown, JsonInputFileFailure>> =>
withRegularFile(
withJsonInputFile(
path,
async (handle, stats) => {
let found = false;
let value: unknown;
const physicalRead: JsonInputPhysicalReadObservation = {};
try {
const native = await readNativeJson(
handle,
@@ -269,44 +362,59 @@ export const readJsonInputFile = (
operation,
signal,
snapshot,
physicalRead,
);
if (native !== undefined)
return native.ok && snapshot
? await sealOwnedJsonInput(native.value, operation, signal)
: native;
await pipeline(
decodedChunks(handle, stats.size, path, signal),
// Fuse synchronous token stages so only file chunks cross the async
// stream boundary. Admission still precedes scalar/value assembly.
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(operation, stats.size, path, snapshot ? 2 : 1),
streamValues(),
(row: unknown): typeof none => {
if (
found ||
typeof row !== "object" ||
row === null ||
!("key" in row) ||
row.key !== 0 ||
!("value" in row)
)
throw new Error("Unexpected JSON parser result");
found = true;
value = row.value;
return none;
},
await withJsonInputPipeline(signal, (pipelineSignal) =>
pipeline(
decodedChunks(
handle,
stats.size,
path,
signal,
undefined,
physicalRead,
),
// Fuse synchronous token stages so only file chunks cross the async
// stream boundary. Admission still precedes scalar/value assembly.
asStream(
fun(
parser({
jsonStreaming: false,
streamValues: false,
packKeys: false,
packStrings: false,
packNumbers: false,
}),
packJsonScalars(operation, stats.size, path),
admitJsonAssembly(
operation,
stats.size,
path,
snapshot ? 2 : 1,
),
streamValues(),
(row: unknown): typeof none => {
if (
found ||
typeof row !== "object" ||
row === null ||
!("key" in row) ||
row.key !== 0 ||
!("value" in row)
)
throw new Error("Unexpected JSON parser result");
found = true;
value = row.value;
return none;
},
),
),
pipelineSignal === undefined ? {} : { signal: pipelineSignal },
),
signal === undefined ? {} : { signal },
);
return found
? snapshot
@@ -314,6 +422,7 @@ export const readJsonInputFile = (
: ok(value)
: err(invalidJson(operation, "JSON document is empty"));
} catch (cause: unknown) {
if (physicalRead.failure !== undefined) throw physicalRead.failure;
const failure = jsonReadFailure(cause, operation, stats.size, path);
if (failure !== undefined) return err(failure);
throw cause;
@@ -526,6 +635,7 @@ async function* decodedChunks(
path: string,
signal: AbortSignal | undefined,
observe?: (bytes: Uint8Array) => void,
physicalRead: JsonInputPhysicalReadObservation = {},
): AsyncGenerator<string> {
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
let leading = true;
@@ -539,14 +649,16 @@ async function* decodedChunks(
const bytes = Buffer.allocUnsafe(READ_CHUNK_BYTES);
let observedBytes = 0;
while (true) {
signal?.throwIfAborted();
const read = await handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
observedBytes,
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(
bytes,
0,
Math.min(bytes.length, admittedBytes - observedBytes + 1),
observedBytes,
),
);
signal?.throwIfAborted();
assertRegularFileActive(signal);
if (read.bytesRead === 0) break;
observedBytes += read.bytesRead;
if (observedBytes > admittedBytes) throw new RegularFileChangedError(path);
@@ -569,13 +681,16 @@ const readPrefix = async (
handle: FileHandle,
size: number,
signal: AbortSignal | undefined,
physicalRead: JsonInputPhysicalReadObservation,
): Promise<{ readonly bytes: Buffer; readonly complete: boolean }> => {
const bytes = Buffer.allocUnsafe(size);
let offset = 0;
while (offset < bytes.length) {
signal?.throwIfAborted();
const read = await handle.read(bytes, offset, bytes.length - offset, null);
signal?.throwIfAborted();
assertRegularFileActive(signal);
const read = await readPhysicalJsonInput(physicalRead, () =>
handle.read(bytes, offset, bytes.length - offset, null),
);
assertRegularFileActive(signal);
if (read.bytesRead === 0)
return { bytes: bytes.subarray(0, offset), complete: true };
offset += read.bytesRead;
@@ -591,9 +706,10 @@ const readNativeJson = async (
operation: string,
signal?: AbortSignal,
snapshot = false,
physicalRead: JsonInputPhysicalReadObservation = {},
): Promise<Result<unknown, JsonInputFileFailure> | undefined> => {
if (!nativeParseFits(size, snapshot)) return undefined;
const prefix = await readPrefix(handle, size + 1, signal);
const prefix = await readPrefix(handle, size + 1, signal, physicalRead);
if (!prefix.complete || prefix.bytes.length !== size)
throw new RegularFileChangedError(path);
const parsed = parseUtf8Json(
+16 -2
View File
@@ -7,7 +7,11 @@ import {
ArtifactResourceScope,
type ArtifactResourceOwner,
} from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
ArtifactReaderFailure,
artifactPrimaryCleanupLeases,
} from "../artifacts/ArtifactReader.js";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -922,7 +926,9 @@ export class LinuxHopperPublisher {
"integrity",
"Retained Hopper evidence exceeds its resource byte budget.",
);
const bytes = await readBoundedFileBytes(handle, stats.size, signal);
const bytes = await readBoundedFileBytes(handle, stats.size, {
signal,
});
if (bytes === undefined)
throw new HopperReleaseError(
"integrity",
@@ -1061,12 +1067,17 @@ export class LinuxHopperPublisher {
export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
const causes: unknown[] = [];
const observations = [];
const cleanupLeases = new Set<AnalysisCleanupLease>();
let cleanupLeaseAuthorityKnown = true;
for (const scope of pendingCleanup) {
try {
await scope.close();
pendingCleanup.delete(scope);
} catch (cause: unknown) {
causes.push(cause);
const leases = artifactPrimaryCleanupLeases(cause);
if (leases === undefined) cleanupLeaseAuthorityKnown = false;
else for (const lease of leases) cleanupLeases.add(lease);
observations.push(
ArtifactReaderFailure.cleanupObservation(
cause,
@@ -1088,6 +1099,9 @@ export const retryLinuxHopperPublisherCleanup = async (): Promise<void> => {
{
cause: new AggregateError(causes, "Hopper publisher cleanup failures"),
cleanup,
cleanupLeases: cleanupLeaseAuthorityKnown
? [...cleanupLeases]
: undefined,
},
);
}
+48 -10
View File
@@ -5,6 +5,7 @@ import ignore from "ignore";
import {
readRegularFileText,
RegularFileCleanupFailure,
RegularFileReadFailure,
retryRegularFileCleanup,
} from "./RegularFileRead.js";
@@ -17,6 +18,7 @@ import {
type ReferenceSourceImportOptions,
} from "./ReferenceSourceImportTypes.js";
import { rootFilesystemFailure } from "../reference/ReferenceSourceReaderErrors.js";
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
/** Validated inputs ready for filesystem traversal. */
export interface PreparedReferenceSourceImport {
@@ -34,6 +36,25 @@ const failure = (
message,
});
class IgnorePolicyReadCancelled extends Error {
constructor(readonly reason: unknown) {
super("Reference source ignore policy read was cancelled", {
cause: reason,
});
this.name = "IgnorePolicyReadCancelled";
}
}
const isIgnorePolicyCancellation = (cause: unknown): boolean =>
cause instanceof IgnorePolicyReadCancelled;
const ignorePolicyCause = (cause: unknown): unknown =>
cause instanceof IgnorePolicyReadCancelled
? cause.reason
: cause instanceof RegularFileReadFailure
? cause.operationCause
: cause;
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const addMarkedPatterns = (
@@ -102,30 +123,46 @@ const buildIgnored = async (
throw cause;
present = false;
}
signal?.throwIfAborted();
if (signal?.aborted === true)
throw new IgnorePolicyReadCancelled(signal.reason);
if (present) {
let text: string;
try {
text = await readRegularFileText(policyPath, { signal });
text = await readRegularFileText(policyPath, {
signal,
cancellationFailure: (reason) => new IgnorePolicyReadCancelled(reason),
});
} catch (cause: unknown) {
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (cause instanceof IgnorePolicyReadCancelled) throw cause;
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
const operationCancellation =
cause.outcome.kind === "failed"
? regularFileCancellation(cause)
: undefined;
if (retry.cleanup !== undefined) {
const primary =
retry.outcome.kind === "failed"
? retry.outcome.cause
: cause.cleanupCause;
const primaryCause = ignorePolicyCause(primary);
return err({
...failure(
signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primary)}`,
isIgnorePolicyCancellation(primary) ||
operationCancellation !== undefined
? "cancelled"
: "io",
`Reference source ignore policy could not be read at ${policyPath}: ${errorMessage(primaryCause)}`,
),
cleanup: retry.cleanup,
cause: primary,
cause: primaryCause,
});
}
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
if (retry.outcome.kind === "failed") {
if (operationCancellation !== undefined)
throw new IgnorePolicyReadCancelled(operationCancellation.reason);
throw retry.outcome.cause;
}
text = retry.outcome.value;
}
addMarkedPatterns(ignored, [text], "project-ignored");
@@ -166,12 +203,13 @@ export const prepareReferenceSourceImport = async (
secrets: ignore().add([...options.policy.secretPatterns]),
});
} catch (cause: unknown) {
const causeValue = ignorePolicyCause(cause);
return err({
...failure(
options.signal?.aborted === true ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${errorMessage(cause)}`,
isIgnorePolicyCancellation(cause) ? "cancelled" : "io",
`Reference source ignore policy could not be read at ${join(root.value, ".gitignore")}: ${errorMessage(causeValue)}`,
),
cause,
cause: causeValue,
});
}
});
+90 -23
View File
@@ -10,7 +10,9 @@ import {
import {
RegularFileCleanupFailure,
RegularFileCancelled,
regularFileCancellation,
withOwnedRegularFile,
OwnedRegularFileOperationFailure,
type RegularFileOperationOutcome,
} from "../filesystem/OwnedRegularFile.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
@@ -24,6 +26,18 @@ export interface RegularFileReadOptions {
readonly resources?: ArtifactResourceScope;
/** Runs on the opened file's own state before any byte is read; throw to refuse the read. */
readonly admit?: (state: Stats) => void;
/** Tags cancellation at the bounded reader's cooperative checkpoints. */
readonly cancellationFailure?: (reason: unknown) => Error;
}
/** Preserves a physical descriptor-read cause until its owner has finalized. */
export class RegularFileReadFailure extends OwnedRegularFileOperationFailure {
override readonly _tag = "RegularFileReadFailure";
constructor(operationCause: unknown) {
super(operationCause);
this.name = this._tag;
}
}
export class InvalidUtf8FileError extends TypeError {
@@ -52,6 +66,7 @@ export const retryRegularFileCleanup = async <Value>(
}> => {
const outcome: RegularFileReadOutcome<Value> =
failure.outcome.kind === "failed" &&
failure.operationFailure === undefined &&
failure.outcome.cause instanceof RegularFileOperationCancelled &&
!(signal?.aborted === true && failure.outcome.cause === signal.reason)
? { kind: "failed", cause: failure.outcome.cause.reason }
@@ -93,7 +108,10 @@ export const retryRegularFileCleanupFailures = async (
? retried.outcome.cause
: current.cleanupCause;
const nested =
current.outcome.kind === "failed" ? current.outcome.cause : undefined;
current.operationFailure === undefined &&
current.outcome.kind === "failed"
? current.outcome.cause
: undefined;
if (!(nested instanceof RegularFileCleanupFailure)) break;
current = nested;
}
@@ -153,21 +171,51 @@ export const withRegularFile = async <Value>(
// Caller reasons are opaque, including objects shaped like file failures.
// They do not transfer ownership of any descriptor to this operation.
if (signal?.aborted === true && cause === signal.reason) throw cause;
if (cause instanceof RegularFileOperationCancelled) throw cause.reason;
if (cause instanceof RegularFileReadFailure) throw cause;
if (
cause instanceof RegularFileCancelled &&
cause.owner.handle === admitted
)
throw cause.reason;
if (
!(cause instanceof RegularFileCleanupFailure) ||
resources === undefined
cause.owner.handle !== admitted
)
throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
if (retry.cleanup !== undefined) throw cause;
if (retry.outcome.kind === "failed") throw retry.outcome.cause;
return retry.outcome.value;
if (cause instanceof RegularFileCleanupFailure) {
if (resources === undefined) throw cause;
const retry = await retryRegularFileCleanup(cause, resources, signal);
if (retry.cleanup !== undefined) throw cause;
if (retry.outcome.kind === "failed") {
if (
cause.operationFailure === undefined &&
signal?.aborted === true &&
retry.outcome.cause === signal.reason
)
throw signal.reason;
const cancellation =
cause.operationFailure === undefined
? (regularFileCancellation(retry.outcome.cause) ??
regularFileCancellation(cause))
: undefined;
if (cancellation !== undefined) {
if (options.cancellationFailure !== undefined)
throw options.cancellationFailure(cancellation.reason);
throw cancellation.reason;
}
if (
cause.operationFailure !== undefined &&
options.cancellationFailure !== undefined
)
throw cause.operationFailure;
throw retry.outcome.cause;
}
return retry.outcome.value;
}
const cancellation = regularFileCancellation(cause);
if (cancellation !== undefined) {
if (options.cancellationFailure !== undefined)
throw options.cancellationFailure(cancellation.reason);
throw cancellation.reason;
}
if (options.cancellationFailure !== undefined)
throw new RegularFileReadFailure(cause);
throw cause;
}
};
const value = await (resources === undefined
@@ -175,6 +223,8 @@ export const withRegularFile = async <Value>(
: resources.run(operation));
// A retained close may have recovered after the descriptor owner's outcome
// was captured. Include that retry and the scope's finalization in cancellation.
if (signal?.aborted === true && options.cancellationFailure !== undefined)
throw options.cancellationFailure(signal.reason);
signal?.throwIfAborted();
return value;
};
@@ -221,19 +271,36 @@ const readRegularFileContent = <Value>(
path: string,
decode: (bytes: Buffer, state: Stats) => Value,
options: RegularFileReadOptions,
): Promise<Value> =>
withRegularFile(
): Promise<Value> => {
const operation = withRegularFile(
path,
async (handle, stats) => {
options.admit?.(stats);
const bytes = await readBoundedFileBytes(
handle,
stats.size,
options.signal,
);
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return decode(bytes, stats);
try {
options.admit?.(stats);
const bytes = await readBoundedFileBytes(handle, stats.size, {
...(options.signal === undefined ? {} : { signal: options.signal }),
cancellationFailure: (reason) =>
new RegularFileOperationCancelled(reason),
operationFailure: (cause) => new RegularFileReadFailure(cause),
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return decode(bytes, stats);
} catch (cause: unknown) {
if (
options.cancellationFailure !== undefined &&
!(cause instanceof RegularFileOperationCancelled) &&
!(cause instanceof RegularFileReadFailure)
)
throw new RegularFileReadFailure(cause);
throw cause;
}
},
options,
);
if (options.cancellationFailure !== undefined) return operation;
return operation.catch((cause: unknown) => {
if (cause instanceof RegularFileReadFailure) throw cause.operationCause;
throw cause;
});
};
+3 -6
View File
@@ -53,12 +53,9 @@ const readSkillFileState = async (
withRegularFile(
path,
async (handle, stats) => {
const bytes = await readBoundedFileBytes(
handle,
stats.size,
undefined,
stats.size,
);
const bytes = await readBoundedFileBytes(handle, stats.size, {
expectedSize: stats.size,
});
if (bytes === undefined || bytes.length !== stats.size)
throw new RegularFileChangedError(path);
return { bytes, mode: stats.mode & 0o7777 };
@@ -1,11 +1,15 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { analysisViewCaptureEvidence } from "../../../tests/fixtures/analysisView.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { JsonInputPhysicalReadFailure } from "../JsonInputFile.js";
import { failFileReadAfterAbort } from "../../../tests/support/failFileReadAfterAbort.js";
import { holdFirstFileRead } from "../../../tests/support/holdFirstFileRead.js";
import {
estimateJsonValueBytes,
readEvidenceFileSource,
@@ -43,12 +47,23 @@ describe("evidence-file source", () => {
it("refuses a cancelled request before reading and reads within the caller's resource scope", async () => {
const controller = new AbortController();
controller.abort();
expect(
await readEvidenceFileSource("/nonexistent/capture.json", {
const reason = new JsonInputPhysicalReadFailure(
new Error("opaque caller reason with physical-failure shape"),
);
controller.abort(reason);
const cancelled = await readEvidenceFileSource(
"/nonexistent/capture.json",
{
signal: controller.signal,
}),
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
},
);
expect(cancelled).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (cancelled.ok || !(cancelled.error instanceof AnalysisCancelledError))
throw new Error("Expected cancelled analysis-view read");
expect(cancelled.error.cause).toBe(reason);
const path = join(
await createTestTempDirectory("rea-evidence-file-source-"),
"capture.json",
@@ -62,3 +77,120 @@ describe("evidence-file source", () => {
expect(read.value).toEqual(JSON.parse(JSON.stringify(parent)));
});
});
describe("evidence-file read failure provenance", () => {
it("keeps a physical null read cause after the analysis-view request aborts", async () => {
const root = await createTestTempDirectory("rea-view-late-io-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const controller = new AbortController();
await failFileReadAfterAbort(
path,
controller,
new Error("later abort"),
null,
);
try {
const result = await readEvidenceFileSource(path, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "io", path },
});
if (result.ok) throw new Error("Expected physical JSON read failure");
expect(result.error.cause).toBeNull();
} finally {
vi.restoreAllMocks();
}
});
it("preserves identical physical and abort causes for analysis-view reads", async () => {
const root = await createTestTempDirectory("rea-view-identical-abort-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const controller = new AbortController();
const physical = Object.assign(new Error("descriptor EIO"), {
code: "EIO",
});
await failFileReadAfterAbort(path, controller, physical, physical);
try {
const result = await readEvidenceFileSource(path, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "EvidenceFileError", reason: "io" },
});
if (result.ok) throw new Error("Expected physical JSON read failure");
expect(result.error.cause).toBe(physical);
} finally {
vi.restoreAllMocks();
}
});
it("preserves the caller cancellation reason during an analysis-view read", async () => {
const root = await createTestTempDirectory("rea-view-read-cancel-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const reader = await holdFirstFileRead(path);
const controller = new AbortController();
const reason = new Error("stop analysis-view read");
try {
const pending = readEvidenceFileSource(path, {
signal: controller.signal,
});
await reader.firstRead;
controller.abort(reason);
reader.release();
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
operation: "inspect_analysis_view",
},
});
if (result.ok || !(result.error instanceof AnalysisCancelledError))
throw new Error("Expected cancelled analysis-view read");
expect(result.error.cause).toBe(reason);
} finally {
controller.abort(reason);
reader.release();
vi.restoreAllMocks();
}
});
it("does not treat a caller reason with physical-read shape as a physical read", async () => {
const root = await createTestTempDirectory("rea-view-opaque-cancel-");
const path = join(root, "capture.json");
await writeFile(path, JSON.stringify(analysisViewCaptureEvidence()));
const reader = await holdFirstFileRead(path);
const controller = new AbortController();
const reason = new JsonInputPhysicalReadFailure(
new Error("opaque caller reason with physical-failure shape"),
);
try {
const pending = readEvidenceFileSource(path, {
signal: controller.signal,
});
await reader.firstRead;
controller.abort(reason);
reader.release();
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
if (result.ok || !(result.error instanceof AnalysisCancelledError))
throw new Error(
"Expected caller cancellation, not physical I/O failure",
);
expect(result.error.cause).toBe(reason);
} finally {
controller.abort(reason);
reader.release();
vi.restoreAllMocks();
}
});
});
@@ -16,7 +16,6 @@ import {
} from "../../domain/evidenceErrors.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { readJsonFile } from "../JsonFiles.js";
import { RegularFileCleanupFailure } from "../RegularFileRead.js";
/** The request's cancellation and the scope that owns descriptors whose close failed. */
export interface EvidenceFileReadOptions {
@@ -79,25 +78,27 @@ export const readEvidenceFileSource = async (
): Promise<Result<Evidence, AnalysisError>> => {
const { signal, resources } = options;
if (signal?.aborted)
return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION));
const loaded = await readJsonFile(path, signal, resources).catch(
(cause: unknown) => {
// A failed close transfers a live handle owner; preserve that failure.
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause,
}),
);
throw cause;
},
);
if (!loaded.ok) return loaded;
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: signal.reason,
}),
);
const loaded = await readJsonFile(path, signal, resources);
if (!loaded.ok) {
if (loaded.error instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: loaded.error.cause,
}),
);
return loaded;
}
if (signal?.aborted)
return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION));
return err(
new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, {
cause: signal.reason,
}),
);
const headroom = requireAuthenticationHeadroom(loaded.value, path);
if (!headroom.ok) return headroom;
try {
@@ -45,16 +45,22 @@ export class AndroidAnalysisService {
if (!executed.ok) return executed;
const execution = executed.value;
return completeProviderEvidence(
createEvidence(execution.subject ?? target.value, execution.provider, {
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence:
operation === "inspect_android_method" ? "derived" : "observed",
}),
createEvidence(
execution.subject === undefined
? target.value
: (execution.subject ?? undefined),
execution.provider,
{
operation,
parameters: jsonObjectSchema.parse(request.data.input),
result: execution.result,
rawResult: execution.rawResult,
limitations: execution.limitations,
locations: execution.locations,
confidence:
operation === "inspect_android_method" ? "derived" : "observed",
},
),
options?.signal,
);
}
@@ -175,6 +175,84 @@ it("rebinds replayed target subjects while preserving child artifact subjects",
).toMatchObject(childSubject);
});
it("binds an omitted subject to the selected target in snapshot replay", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const relocatedTarget = { ...target, path: "/tmp/relocated-default-target" };
const parameters = { address: "0x2a00" };
const execution = createAnalysisExecution(
{ name: "default-bound" },
ANALYSIS_SNAPSHOT_PROVIDER,
{ analysisProfile: profile },
);
expect(execution).not.toHaveProperty("subject");
cache.record({
target,
profile,
operation: "procedure_info",
parameters,
execution,
});
const snapshot = cache.export(
target,
profile,
createEvidenceBundle([
createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "procedure_info",
parameters,
result: { name: "default-bound" },
analysisProfile: profile,
}),
]),
);
if (!snapshot.ok) throw snapshot.error;
expect(snapshot.value.entries[0]?.execution.subject?.path).toBe(target.path);
const reparsed = parseAnalysisSnapshot(
JSON.parse(serializeAnalysisSnapshot(snapshot.value)),
);
expect(reparsed.entries[0]?.execution.subject?.path).toBe(target.path);
cache.clear();
const imported = cache.import(
reparsed,
{ target: relocatedTarget, profile },
() => ok(0),
);
expect(imported.ok).toBe(true);
expect(
cache.lookup(relocatedTarget, profile, "procedure_info", parameters)
?.subject?.path,
).toBe(relocatedTarget.path);
});
it("preserves an explicitly unbound subject through snapshot replay", () => {
const cache = new AnalysisSnapshotCache();
const target = ANALYSIS_SNAPSHOT_TARGET;
const profile = ANALYSIS_SNAPSHOT_PROFILE;
const parameters = { address: "0x3000" };
cache.record({
target,
profile,
operation: "procedure_info",
parameters,
execution: createAnalysisExecution(
{ name: "unbound" },
ANALYSIS_SNAPSHOT_PROVIDER,
{ subject: null, analysisProfile: profile },
),
});
expect(
cache.lookup(
{ ...target, path: "/tmp/relocated-unbound-target" },
profile,
"procedure_info",
parameters,
)?.subject,
).toBeNull();
});
describe("analysis snapshot cache capacity", () => {
it("shares capacity across query kinds while allowing replacements and fresh partitions", () => {
const cache = new AnalysisSnapshotCache();
@@ -254,7 +254,7 @@ export class AnalysisSnapshotCache {
readonly limitations: readonly string[];
readonly relationshipCoverage?: AnalysisExecution["relationshipCoverage"];
readonly locations: AnalysisExecution["locations"];
readonly subject: AnalysisExecution["subject"];
readonly subject: NonNullable<AnalysisExecution["subject"]> | null;
};
}): void {
this.select(input.target, input.profile);
@@ -292,7 +292,7 @@ export class AnalysisSnapshotCache {
if (cached === undefined) return undefined;
const cachedSubject = cached.execution.subject;
const subject =
cachedSubject === null
cachedSubject == null
? null
: rebindEvidenceSubjectTargetPath(
{
@@ -328,7 +328,7 @@ export class AnalysisSnapshotCache {
relationshipCoverage: cached.execution.relationship_coverage,
}),
locations: cached.execution.locations,
subject,
subject: subject ?? null,
});
}
@@ -357,7 +357,20 @@ export class AnalysisSnapshotCache {
binding: snapshotBinding(profile),
operation,
parameters,
execution,
execution: {
...execution,
subject:
execution.subject === undefined
? {
path: target.path,
sha256: target.sha256,
format: target.format,
...(target.architecture === undefined
? {}
: { architecture: target.architecture }),
}
: execution.subject,
},
});
this.#entries.set(entry.query_id, entry);
}
+418 -13
View File
@@ -7,9 +7,10 @@ import {
} from "../../domain/analysisProfile.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { createEvidence } from "../../domain/evidence.js";
@@ -48,7 +49,19 @@ import {
} from "./BinarySessionExecution.js";
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import {
resolveAppBundleResourceRoot,
type ResolvedAppBundleResourceRoot,
} from "../AppBundleExecutable.js";
import {
BUNDLE_RESOURCE_OPERATIONS,
type ActiveBundleResourceSession,
type BundleResourceClientFactory,
} from "./BundleResourceSession.js";
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
);
@@ -69,6 +82,56 @@ interface SessionBinding extends SessionRun {
nativeSourceAdmitted: boolean;
}
interface CleanupFailureTracker {
readonly error: AnalysisError | undefined;
readonly onFailure: (failure: AnalysisError) => void;
}
const trackCleanupFailures = (
signal: AbortSignal | undefined,
operation: string,
): CleanupFailureTracker => {
let error: AnalysisError | undefined;
let cleanupFailure: AnalysisError | undefined;
let cancellation: AnalysisCancelledError | undefined;
const observed = new Set<AnalysisError>();
return {
get error() {
return error;
},
onFailure: (failure) => {
if (
observed.has(failure) ||
(failure.cleanupPrimary !== undefined &&
observed.has(failure.cleanupPrimary))
)
return;
observed.add(failure);
if (cleanupFailure === undefined) {
if (isAborted(signal))
cancellation = new AnalysisCancelledError(operation, {
cause: signal?.reason,
});
cleanupFailure = failure;
} else {
cleanupFailure = analysisErrorWithCleanupFailure(
cleanupFailure,
failure,
operation,
);
}
error =
cancellation === undefined
? cleanupFailure
: analysisErrorWithCleanupFailure(
cancellation,
cleanupFailure,
operation,
);
},
};
};
/**
* Owns the single active target shared by CLI and MCP adapters.
*
@@ -94,6 +157,9 @@ export class BinarySession
readonly #calls = new Map<Promise<unknown>, number>();
readonly #promptCompletionControllers = new Set<AbortController>();
readonly #providerRouter: SessionProviderRouter;
readonly #bundleResourceFactory: BundleResourceClientFactory | undefined;
#activeBundleResources: ActiveBundleResourceSession | undefined;
#pendingBundleResourceCleanup: ActiveBundleResourceSession[] = [];
readonly #runtimeUnavailability = new Map<
string,
{ readonly reason: string }
@@ -103,9 +169,161 @@ export class BinarySession
constructor(
providerRouter: SessionProviderRouter,
records: InvestigationRecords = new InvestigationRecords(),
bundleResourceFactory?: BundleResourceClientFactory,
) {
super(records);
this.#providerRouter = providerRouter;
this.#bundleResourceFactory = bundleResourceFactory;
}
/** Open an app resource root without requiring CFBundleExecutable. */
openBundleResources(
path: string,
options: Pick<ExecutionOptions, "signal"> = {},
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (this.#bundleResourceFactory === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
"open_bundle_resources",
"No app resource reader is configured.",
),
);
const resolved = await resolveAppBundleResourceRoot(path);
if (!resolved.ok) return resolved;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const runId = randomUUID();
let bundleClient: Awaited<
ReturnType<BundleResourceClientFactory["createClient"]>
>;
try {
bundleClient = await this.#bundleResourceFactory.createClient(
resolved.value,
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
"bundle-resources",
"open_bundle_resources",
{ cause },
),
);
}
const candidate: ActiveBundleResourceSession = {
root: resolved.value,
client: bundleClient.client,
capabilities: bundleClient.capabilities,
runId,
};
const started = await candidate.client.execute("health", {}, options);
if (!started.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? started.error
: analysisErrorWithCleanupFailure(
started.error,
closed.error,
"open_bundle_resources",
),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? cancelled
: analysisErrorWithCleanupFailure(
cancelled,
closed.error,
"open_bundle_resources",
),
);
}
const cleanupFailures = trackCleanupFailures(
options.signal,
"open_bundle_resources",
);
const closed = await this.#closeActive(
{},
false,
cleanupFailures.onFailure,
);
if (!closed.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
const primary = cleanupFailures.error ?? closed.error;
return err(
cleanupFailures.error ??
(candidateClosed.ok
? primary
: analysisErrorWithCleanupFailure(
primary,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
return err(
cleanupFailures.error ??
(candidateClosed.ok
? cancelled
: analysisErrorWithCleanupFailure(
cancelled,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
this.#activeBundleResources = candidate;
this.resetSnapshotInvalidation();
return resolved;
});
}
/**
@@ -123,7 +341,11 @@ export class BinarySession
): Promise<Result<BinaryTarget, AnalysisError> | undefined> {
const live = await this.#retainedClientLive(retained.client, options);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
if (!live) return undefined;
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
@@ -158,6 +380,7 @@ export class BinarySession
/** External live state cannot be replayed by a direct or composed CLI workflow. */
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
if (this.#activeBundleResources !== undefined) return false;
const capabilities = this.#active?.route.capabilities;
const descriptor = capabilities?.get(operation);
if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
@@ -201,7 +424,13 @@ export class BinarySession
options: BinarySessionOpenOptions = {},
): Promise<Result<ResolvedSessionOpen, AnalysisError>> {
if (isAborted(options.signal))
return Promise.resolve(err(new AnalysisCancelledError("open_binary")));
return Promise.resolve(
err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
return resolveSessionTarget({
router: this.#providerRouter,
current: this.#active,
@@ -285,7 +514,11 @@ export class BinarySession
): Promise<Result<BinarySessionOpenOptions, AnalysisError>> {
if (options.loadSnapshot === undefined) return ok(options);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const loaded = await options.loadSnapshot();
if (!loaded.ok) return loaded;
const { loadSnapshot: _loaded, ...rest } = options;
@@ -300,13 +533,21 @@ export class BinarySession
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const resolved = await resolve();
if (!resolved.ok) return resolved;
const { target, route, sameTarget } = resolved.value;
const { profile } = route;
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const activeProfile = this.#active?.profile;
const sameProfile =
activeProfile === null || activeProfile === undefined
@@ -314,11 +555,22 @@ export class BinarySession
: profile !== null && analysisProfilesEqual(activeProfile, profile);
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const retained = sameTarget && sameProfile ? this.#active : undefined;
if (retained !== undefined) {
const fastPath = await this.#tryRetainedFastPath(
@@ -363,10 +615,43 @@ export class BinarySession
await this.#failedOpen(
run,
previous,
new AnalysisCancelledError("open_binary"),
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
}
const cleanupFailures = trackCleanupFailures(options.signal, "open_binary");
if (this.#activeBundleResources !== undefined) {
this.#pendingBundleResourceCleanup.push(this.#activeBundleResources);
this.#activeBundleResources = undefined;
const retiredResources = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
if (!retiredResources.ok) {
const primary = cleanupFailures.error ?? retiredResources.error;
const failed = await this.#failedOpen(
run,
previous,
primary,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
});
const failed = await this.#failedOpen(
run,
previous,
cancelled,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
const profilelessWorkflowProfile =
profile === null ? profilelessNativeDispatchProfile(route) : undefined;
this.#active = {
@@ -409,6 +694,14 @@ export class BinarySession
) => Promise<Result<null, AnalysisError>>,
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
operationName,
"External mutation requires an active executable analysis target.",
),
);
if (isAborted(signal))
return err(new AnalysisCancelledError(operationName));
await this.#drainCalls(admittedThrough);
@@ -499,14 +792,20 @@ export class BinarySession
readonly retainProviderDocuments?: boolean;
},
clearRecords = true,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
const previous = this.#active;
const previousResources = this.#activeBundleResources;
this.#active = undefined;
this.#activeBundleResources = undefined;
if (previousResources !== undefined)
this.#pendingBundleResourceCleanup.push(previousResources);
const closed =
previous === undefined
? await this.#retryPendingCleanup(
options.progress,
options.retainProviderDocuments,
onCleanupFailure,
)
: await this.#retireClient(previous, {
...(options.progress === undefined
@@ -515,12 +814,20 @@ export class BinarySession
...(options.retainProviderDocuments === true
? { retainDocument: true }
: {}),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
});
if (clearRecords) this.clearSessionRecords();
this.#clearRuntimeAvailability();
const fileResources = await this.closeWriteResources();
if (!fileResources.ok) onCleanupFailure?.(fileResources.error);
const candidateResources = await this.#providerRouter.closeOwnedResources();
if (!candidateResources.ok) onCleanupFailure?.(candidateResources.error);
const bundleClosed = await this.#retryPendingBundleResourceCleanup(
options.progress,
onCleanupFailure,
);
const resourceFailures = [
...(bundleClosed.ok ? [] : [bundleClosed.error]),
...(fileResources.ok ? [] : [fileResources.error]),
...(candidateResources.ok ? [] : [candidateResources.error]),
];
@@ -544,22 +851,61 @@ export class BinarySession
return resourceFailure === undefined ? ok(null) : err(resourceFailure);
}
async #retryPendingBundleResourceCleanup(
progress?: ExecutionOptions["progress"],
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
if (this.#pendingBundleResourceCleanup.length === 0) return ok(null);
const pending = this.#pendingBundleResourceCleanup;
const retained: ActiveBundleResourceSession[] = [];
let failure: AnalysisError | undefined;
for (const session of pending) {
const closed = await closeAnalysisClient(
session.client,
"bundle-resources",
{
...(progress === undefined ? {} : { progress }),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
},
);
if (!closed.ok) {
retained.push(session);
failure =
failure === undefined
? closed.error
: analysisErrorWithCleanupFailure(
failure,
closed.error,
"close_binary",
);
}
}
this.#pendingBundleResourceCleanup = retained;
return failure === undefined ? ok(null) : err(failure);
}
async #retireClient(
run: SessionRun,
options: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
readonly onCleanupFailure?: (failure: AnalysisError) => void;
} = {},
): Promise<Result<null, AnalysisError>> {
this.#pendingCleanup = {
run,
retainDocument: options.retainDocument === true,
};
return this.#retryPendingCleanup(options.progress);
return this.#retryPendingCleanup(
options.progress,
undefined,
options.onCleanupFailure,
);
}
async #retryPendingCleanup(
progress?: ExecutionOptions["progress"],
retainDocument?: boolean,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
let pending = this.#pendingCleanup;
if (pending === undefined) return ok(null);
@@ -573,6 +919,7 @@ export class BinarySession
{
...(progress === undefined ? {} : { progress }),
...(pending.retainDocument ? { retainDocument: true } : {}),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
},
);
if (closed.ok) this.#pendingCleanup = undefined;
@@ -583,8 +930,12 @@ export class BinarySession
run: SessionRun,
previous: SessionBinding | undefined,
primary: AnalysisError,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<AnalysisError> {
const closed = await this.#retireClient(run);
const closed = await this.#retireClient(
run,
onCleanupFailure === undefined ? {} : { onCleanupFailure },
);
if (!closed.ok)
return analysisErrorWithCleanupFailure(
primary,
@@ -606,11 +957,22 @@ export class BinarySession
/** Describe the active binary session. */
status(options: { readonly signal?: AbortSignal } = {}): Promise<JsonValue> {
const run = this.#active ?? this.#pendingCleanup?.run;
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
resourceRun ??
this.#pendingBundleResourceCleanup[0] ??
this.#pendingCleanup?.run;
const client = run?.client;
return binarySessionStatus({
...options,
target: this.#active?.target,
...(resourceRun === undefined
? {}
: {
bundleResourceRoot: resourceRun.root,
resourceCapabilities: resourceRun.capabilities,
}),
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeUnavailability: this.#runtimeUnavailability,
@@ -620,6 +982,7 @@ export class BinarySession
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
cleanupPending:
this.#pendingCleanup !== undefined ||
this.#pendingBundleResourceCleanup.length > 0 ||
this.hasPendingFileResources ||
this.#providerRouter.hasPendingOwnedResources(),
});
@@ -632,6 +995,31 @@ export class BinarySession
: structuredClone(this.#active.target);
}
activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined {
const root = this.#activeBundleResources?.root;
return root === undefined ? undefined : structuredClone(root);
}
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return super.exportAnalysisSnapshot();
}
override importAnalysisSnapshot(snapshot: AnalysisSnapshot) {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisSnapshotMismatchError(
"Analysis snapshots do not apply to a resource-only app session because it has no executable identity or replay binding.",
),
);
return super.importAnalysisSnapshot(snapshot);
}
protected activeAnalysisBinding() {
return this.#active;
}
@@ -723,6 +1111,21 @@ export class BinarySession
transition,
);
if (!transitioned.ok) return transitioned;
const resources = this.#activeBundleResources;
if (resources !== undefined) {
if (!BUNDLE_RESOURCE_OPERATIONS.has(name))
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
name,
"This operation requires an executable analysis target; the active session contains only app resource access.",
),
);
const result = await resources.client.execute(name, arguments_, options);
if (!result.ok) return result;
const execution = { ...result.value, subject: null };
return ok(execution);
}
const prepared = prepareSessionExecution({
active: this.#active,
operation: name,
@@ -783,7 +1186,9 @@ export class BinarySession
name !== "trace_dylib_resolution"
) {
const evidence = createEvidence(
execution.value.subject ?? active.target,
execution.value.subject === undefined
? active.target
: (execution.value.subject ?? undefined),
execution.value.provider,
{
operation: name,
@@ -0,0 +1,799 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import {
createAnalysisExecution,
type AnalysisClient,
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { parseBinaryTarget } from "../BinaryTargetResolver.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js";
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { err } from "../../domain/result.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { createEvidence } from "../../domain/evidence.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { ok } from "../../domain/result.js";
import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js";
const identity: ProviderIdentity = {
id: "bundle-resource-fixture",
name: "Bundle resource fixture",
version: "1",
};
const binaryIdentity: ProviderIdentity = {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
};
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-"));
temporaryDirectories.push(rootPath);
await writeFile(
join(rootPath, "Info.plist"),
'<?xml version="1.0"?><plist><dict/></plist>',
);
let openedRoot: string | undefined;
let closed = false;
const client: AnalysisClient = {
execute: async (operation) => {
if (operation === "health")
return { ok: true, value: createAnalysisExecution(null, identity) };
return {
ok: true,
value: createAnalysisExecution(
{ operation, target_sha256: null },
identity,
),
};
},
close: async () => {
closed = true;
return { ok: true, value: null };
},
};
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: (root) => {
openedRoot = root.canonicalPath;
return { client, capabilities: [] };
},
},
);
const opened = await session.openBundleResources(rootPath);
expect(opened.ok).toBe(true);
if (!opened.ok) throw opened.error;
expect(opened.value.executableSha256).toBeNull();
expect(openedRoot).toBe(opened.value.canonicalPath);
expect(
bundleResourceRootSchema.parse({
caller_path: opened.value.callerPath,
canonical_path: opened.value.canonicalPath,
info_plist_path: opened.value.infoPlistPath,
layout: opened.value.layout,
executable_sha256: opened.value.executableSha256,
}).executable_sha256,
).toBeNull();
const result = await session.execute("inspect_plist", {});
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.subject).toBeNull();
const binaryOnly = await session.execute("binary_overview", {});
expect(binaryOnly.ok).toBe(false);
expect(await session.status()).toMatchObject({
open: true,
session_kind: "bundle_resources",
resource_root: { canonical_path: opened.value.canonicalPath },
});
const snapshot = session.exportAnalysisSnapshot();
expect(snapshot.ok).toBe(false);
if (!snapshot.ok)
expect(snapshot.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
const imported = session.importAnalysisSnapshot({} as AnalysisSnapshot);
expect(imported.ok).toBe(false);
if (!imported.ok)
expect(imported.error).toBeInstanceOf(AnalysisSnapshotMismatchError);
const closedResult = await session.close();
expect(closedResult.ok).toBe(true);
expect(closed).toBe(true);
});
it("keeps the exact caller reason for cancellation before resource-root resolution", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-pre-cancel-"));
temporaryDirectories.push(rootPath);
const reason = { origin: "caller-pre-aborted" };
const controller = new AbortController();
controller.abort(reason);
let clientCreated = false;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
clientCreated = true;
throw new Error("pre-aborted call must not create a client");
},
},
);
const result = await session.openBundleResources(rootPath, {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError", cause: reason },
});
expect(clientCreated).toBe(false);
});
it("retains ownership when an unhealthy resource client cannot be closed", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cleanup-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeAttempts = 0;
const client: AnalysisClient = {
execute: async () => err(new ProviderAdapterError(identity.id, "health")),
close: async () => {
closeAttempts += 1;
return closeAttempts === 1
? err(new ProviderAdapterError(identity.id, "close_binary"))
: { ok: true, value: null };
},
};
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{ createClient: () => ({ client, capabilities: [] }) },
);
const opened = await session.openBundleResources(rootPath);
expect(opened.ok).toBe(false);
expect(closeAttempts).toBe(1);
expect(await session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.status()).toMatchObject({ open: false });
const closed = await session.close();
expect(closed.ok).toBe(true);
expect(closeAttempts).toBe(2);
expect(await session.status()).toMatchObject({ cleanup_pending: false });
});
it("cancels a resource switch during prior-client retirement and closes its candidate", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-switch-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let created = 0;
let candidateCloseCalls = 0;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) candidateCloseCalls += 1;
else {
closeStarted();
await closeGate;
}
return { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "prior-resource-retirement" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("prior resource client close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisCancelledError);
if (!result.ok) expect(result.error.cause).toBe(reason);
expect(candidateCloseCalls).toBe(1);
expect(await session.status()).toMatchObject({ open: false });
expect(await session.close()).toMatchObject({ ok: true });
});
it("keeps cancellation primary when resource retirement fails and retries the owner", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-cancel-close-failure-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let created = 0;
let oldCloseAttempts = 0;
let candidateCloseAttempts = 0;
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) {
candidateCloseAttempts += 1;
return { ok: true, value: null };
}
oldCloseAttempts += 1;
if (oldCloseAttempts === 1) {
closeStarted();
await closeGate;
}
return oldCloseAttempts < 3
? err(new ProviderAdapterError(identity.id, "close_binary"))
: { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "resource-close-failed" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(candidateCloseAttempts).toBe(1);
expect(oldCloseAttempts).toBe(2);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({ ok: true });
expect(oldCloseAttempts).toBe(3);
});
it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-close-before-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let created = 0;
let oldCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(identity.id, "close_binary");
const session = new BinarySession(
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
new InvestigationRecords(),
{
createClient: () => {
created += 1;
const candidate = created > 1;
return {
client: {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
if (candidate) {
candidateCloseStarted();
await candidateCloseGate;
return { ok: true, value: null };
}
oldCloseAttempts += 1;
return oldCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
},
capabilities: [],
};
},
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-physical-close-failure" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource switch");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(oldCloseAttempts).toBe(2);
expect(await session.close()).toMatchObject({ ok: true });
});
it("keeps cancellation primary when binary retirement fails before opening resources", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-binary-close-cancel-resource-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let closeStarted!: () => void;
const closing = new Promise<void>((resolve) => {
closeStarted = resolve;
});
let releaseClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseClose = resolve;
});
let binaryCloseAttempts = 0;
let resourceCloseAttempts = 0;
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
if (binaryCloseAttempts === 1) {
closeStarted();
await closeGate;
return err(new ProviderAdapterError(binaryIdentity.id, "close_binary"));
}
return { ok: true, value: null };
},
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "binary-retirement" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("binary close did not start")), 1_000),
),
]);
controller.abort(reason);
releaseClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(resourceCloseAttempts).toBe(1);
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
expect(await session.close()).toMatchObject({ ok: true });
expect(binaryCloseAttempts).toBe(2);
});
it("keeps an earlier resource close failure primary when binary candidate cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-resource-close-before-binary-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let resourceCloseAttempts = 0;
let binaryCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(identity.id, "close_binary");
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
return resourceCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
};
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
if (binaryCloseAttempts === 1) {
candidateCloseStarted();
await candidateCloseGate;
}
return { ok: true, value: null };
},
};
const router = createTestProviderRouter(() => binaryClient);
const route = router.initialRoute();
const parsedTarget = await parseBinaryTarget(process.execPath);
if (!parsedTarget.ok) throw parsedTarget.error;
const session = new BinarySession(router, new InvestigationRecords(), {
createClient: () => ({ client: resourceClient, capabilities: [] }),
});
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-resource-close-failure" };
const opening = session.openResolvedTarget(
{ target: parsedTarget.value, route },
{ signal: controller.signal },
);
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("binary candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed binary open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(binaryCloseAttempts).toBe(1);
expect(resourceCloseAttempts).toBe(1);
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
it("keeps an earlier binary close failure primary when candidate cleanup is cancelled", async () => {
const rootPath = await mkdtemp(
join(tmpdir(), "rea-binary-close-before-cancel-"),
);
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let candidateCloseStarted!: () => void;
const candidateClosing = new Promise<void>((resolve) => {
candidateCloseStarted = resolve;
});
let releaseCandidateClose!: () => void;
const candidateCloseGate = new Promise<void>((resolve) => {
releaseCandidateClose = resolve;
});
let binaryCloseAttempts = 0;
let resourceCloseAttempts = 0;
const originalFailure = new ProviderAdapterError(
binaryIdentity.id,
"close_binary",
);
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : "fixture-name",
binaryIdentity,
),
}),
close: async () => {
binaryCloseAttempts += 1;
return binaryCloseAttempts === 1
? err(originalFailure)
: { ok: true, value: null };
},
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
if (resourceCloseAttempts === 1) {
candidateCloseStarted();
await candidateCloseGate;
}
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.open(process.execPath)).ok).toBe(true);
expect((await session.execute("address_name", {})).ok).toBe(true);
const controller = new AbortController();
const reason = { phase: "after-binary-close-failure" };
const opening = session.openBundleResources(rootPath, {
signal: controller.signal,
});
await Promise.race([
candidateClosing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource candidate close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseCandidateClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected failed resource open");
expect(result.error).toBe(originalFailure);
expect(result.error.cleanupPrimary).toBeUndefined();
expect(binaryCloseAttempts).toBe(1);
expect(resourceCloseAttempts).toBe(1);
expect(await session.close()).toMatchObject({ ok: true });
expect(binaryCloseAttempts).toBe(2);
});
it("cancels a binary switch during resource retirement and retains null-subject Evidence", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-cancel-binary-"));
temporaryDirectories.push(rootPath);
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
let resourceCloseStarted!: () => void;
const closing = new Promise<void>((resolve) => {
resourceCloseStarted = resolve;
});
let releaseResourceClose!: () => void;
const closeGate = new Promise<void>((resolve) => {
releaseResourceClose = resolve;
});
let resourceCloseAttempts = 0;
const binaryClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health"
? null
: { document: "fixture", procedure_count: 0, string_count: 0 },
binaryIdentity,
),
}),
close: async () => ({ ok: true, value: null }),
};
const resourceClient: AnalysisClient = {
execute: async (operation) => ({
ok: true,
value: createAnalysisExecution(
operation === "health" ? null : { operation, target_sha256: null },
identity,
{ subject: null },
),
}),
close: async () => {
resourceCloseAttempts += 1;
resourceCloseStarted();
if (resourceCloseAttempts === 1) {
await closeGate;
return err(new ProviderAdapterError(identity.id, "close_binary"));
}
return { ok: true, value: null };
},
};
const session = new BinarySession(
createTestProviderRouter(() => binaryClient, {
resolveAnalysisProfile: async () =>
ok({
profile: createAnalysisProfile(
{ ...binaryIdentity, version: binaryIdentity.version ?? "1" },
{ fixture: true },
),
}),
}),
new InvestigationRecords(),
{
createClient: () => ({ client: resourceClient, capabilities: [] }),
},
);
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
const observed = await session.execute("inspect_plist", {});
expect(observed.ok).toBe(true);
if (!observed.ok) throw observed.error;
const recorded = session.recordEvidence(
createEvidence(undefined, identity, {
operation: "inspect_plist",
parameters: {},
result: observed.value.result,
}),
);
expect(recorded.ok).toBe(true);
const recordsBefore = session.exportEvidenceBundle().records;
expect(recordsBefore).toHaveLength(1);
const evidenceId = recordsBefore[0]?.evidence_id;
if (evidenceId === undefined) throw new Error("Expected retained Evidence");
const controller = new AbortController();
const reason = { phase: "resource-retirement" };
const opening = session.open(process.execPath, { signal: controller.signal });
await Promise.race([
closing,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("resource client close did not start")),
1_000,
),
),
]);
controller.abort(reason);
releaseResourceClose();
const result = await opening;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancelled binary open");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(true);
expect(await session.status()).toMatchObject({ open: false });
expect(session.exportEvidenceBundle().records).toHaveLength(1);
expect(session.evidenceById(evidenceId)).toMatchObject({ subject: null });
expect(await session.status()).toMatchObject({ cleanup_pending: true });
expect(await session.close()).toMatchObject({ ok: true });
expect(resourceCloseAttempts).toBe(2);
});
@@ -3,6 +3,7 @@ import type { AnalysisProvider } from "../AnalysisProvider.js";
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
import { BinarySession } from "./BinarySession.js";
import { SessionProviderRouter } from "./SessionProviderRouter.js";
import type { BundleResourceClientFactory } from "./BundleResourceSession.js";
/**
* Compose one provider-neutral session from deep-provider selection and
@@ -11,8 +12,10 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
export const composeBinarySession = (
registry: AnalysisProviderRegistry,
auxiliaryProviders: readonly AnalysisProvider[] = [],
bundleResourceFactory?: BundleResourceClientFactory,
): BinarySession =>
new BinarySession(
SessionProviderRouter.selectable(registry, auxiliaryProviders),
new InvestigationRecords(),
bundleResourceFactory,
);
@@ -134,6 +134,7 @@ export const bindExecutionTarget = (
: { architecture: target.architecture }),
};
if (
result.value.subject !== undefined &&
result.value.subject !== null &&
result.value.subject.sha256 !== target.sha256 &&
!isArtifactBundleSubject(operation, result.value.subject, target)
@@ -144,7 +145,9 @@ export const bindExecutionTarget = (
`${operation}:subject`,
),
);
return ok({ ...result.value, subject: result.value.subject ?? subject });
return result.value.subject === undefined
? ok({ ...result.value, subject })
: result;
};
const isArtifactBundleSubject = (
@@ -40,6 +40,39 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
* `operationHealthSnapshot`, the way `HopperClient.operationHealth` reports a
* recorded process exit.
*/
it("binds omitted provider subjects to the selected target", async () => {
const { session } = openSession();
try {
const opened = await session.open(process.execPath);
expect(opened.ok).toBe(true);
const execution = await session.execute("address_name", {});
expect(execution.ok).toBe(true);
if (!execution.ok || !opened.ok) return;
expect(execution.value.subject).toEqual({
path: opened.value.path,
sha256: opened.value.sha256,
format: opened.value.format,
...(opened.value.architecture === undefined
? {}
: { architecture: opened.value.architecture }),
});
} finally {
await session.close();
}
});
it("preserves an explicitly unbound provider observation", async () => {
const { session } = openSession({ explicitNullSubject: true });
try {
expect((await session.open(process.execPath)).ok).toBe(true);
const execution = await session.execute("address_name", {});
expect(execution.ok).toBe(true);
if (execution.ok) expect(execution.value.subject).toBeNull();
} finally {
await session.close();
}
});
describe("BinarySession open liveness", () => {
it("retires a locally blocked owner even when its native health is idle", async () => {
const { session, deep } = openSession();
@@ -349,6 +382,7 @@ const openSession = (
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
explicitNullSubject?: boolean;
} = {},
): {
session: BinarySession;
@@ -383,6 +417,7 @@ const killableDeepProvider = (options: {
operationHealth?: boolean;
failOperationInitially?: boolean;
beforeFirstClose?: () => Promise<void>;
explicitNullSubject?: boolean;
}): {
provider: AnalysisProviderCandidate;
created: KillableChild[];
@@ -433,6 +468,10 @@ const killableDeepProvider = (options: {
createAnalysisExecution(
operation === "health" ? null : `liveness:${operation}`,
deepIdentity,
operation !== "health" &&
options.explicitNullSubject === true
? { subject: null }
: {},
),
),
)
@@ -13,6 +13,7 @@ import type { Result } from "../../domain/result.js";
import type { EvidenceIntegrityError } from "../../domain/evidenceErrors.js";
import type { AnalysisSnapshotLoader } from "./BinarySessionOpen.js";
import type { WorkflowSnapshotRecordInput } from "./BinarySessionRecords.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import type {
AnalysisExecution,
@@ -76,6 +77,11 @@ export interface BinarySessionPort
readonly providerId?: AnalysisProviderSelector;
},
): Promise<Result<BinaryTarget, AnalysisError>>;
/** Open a resource-only app root without selecting or inventing an executable target. */
openBundleResources(
path: string,
options?: Pick<ExecutionOptions, "signal">,
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>>;
close(
options?: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
+49 -24
View File
@@ -1,4 +1,5 @@
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
@@ -20,6 +21,8 @@ import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"
interface BinarySessionStatusInput {
readonly target: BinaryTarget | undefined;
readonly bundleResourceRoot?: ResolvedAppBundleResourceRoot;
readonly resourceCapabilities?: readonly CapabilityDescriptor[];
readonly route: SessionProviderRoute;
readonly router: Pick<SessionProviderRouter, "candidateStatuses">;
readonly runtimeUnavailability: ReadonlyMap<
@@ -37,6 +40,8 @@ interface BinarySessionStatusInput {
/** Project internal provider routing state into the caller-visible session status. */
export const binarySessionStatus = async ({
target,
bundleResourceRoot,
resourceCapabilities = [],
route,
router,
runtimeUnavailability,
@@ -47,7 +52,11 @@ export const binarySessionStatus = async ({
cleanupPending = false,
signal,
}: BinarySessionStatusInput): Promise<JsonValue> => {
const routeCapabilities = [...route.capabilities.values()]
const routeCapabilities = (
bundleResourceRoot === undefined
? [...route.capabilities.values()]
: [...resourceCapabilities]
)
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
@@ -64,29 +73,32 @@ export const binarySessionStatus = async ({
});
const capabilities = providerCapabilitySets(routeCapabilities);
const analysisProviderBinding =
route.binding === null
bundleResourceRoot !== undefined
? null
: {
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates = (
await router.candidateStatuses(route, signal)
).map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
: route.binding === null
? null
: {
provider: providerSummary(route.binding.identity),
selection_source: route.binding.selectionSource,
analysis_profile: structuredClone(route.binding.profile),
};
const analysisProviderCandidates =
bundleResourceRoot !== undefined
? []
: (await router.candidateStatuses(route, signal)).map((candidate) => ({
provider: providerSummary(candidate.provider),
availability: structuredClone(candidate.availability),
target_support: structuredClone(candidate.targetSupport),
selected: candidate.selected,
capabilities: providerCapabilitySets(
candidate.capabilities
.slice()
.filter(({ operation }) => operation !== "inventory_artifact")
.sort((left, right) =>
compareUnicodeCodePoints(left.operation, right.operation),
),
),
}));
const common = {
cleanup_pending: cleanupPending,
capabilities,
@@ -105,7 +117,20 @@ export const binarySessionStatus = async ({
),
};
return target === undefined
? { open: false, ...common }
? bundleResourceRoot === undefined
? { open: false, ...common }
: {
open: true,
...common,
session_kind: "bundle_resources",
resource_root: {
caller_path: bundleResourceRoot.callerPath,
canonical_path: bundleResourceRoot.canonicalPath,
info_plist_path: bundleResourceRoot.infoPlistPath,
layout: bundleResourceRoot.layout,
executable_sha256: bundleResourceRoot.executableSha256,
},
}
: {
open: true,
...common,
@@ -0,0 +1,106 @@
import { expect, it } from "vitest";
import { combineBundleResourceClients } from "./BundleResourceSession.js";
import {
createAnalysisExecution,
type AnalysisClient,
type ProviderIdentity,
} from "../AnalysisProvider.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { err } from "../../domain/result.js";
const identity: ProviderIdentity = {
id: "bundle-resource-close-fixture",
name: "Bundle resource close fixture",
version: "1",
};
it("retains both child close failures when a resource owner rejects", async () => {
const attempts: string[] = [];
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
attempts.push("artifact");
throw new Error("artifact close rejected");
},
};
const native: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
attempts.push("native");
throw new Error("native close rejected");
},
};
const closed = await combineBundleResourceClients(artifact, native).close();
expect(attempts.sort()).toEqual(["artifact", "native"]);
expect(closed.ok).toBe(false);
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
});
it("reports the first mixed child failure before awaiting the other owner and retries both", async () => {
let artifactCloseAttempts = 0;
let nativeCloseAttempts = 0;
let nativeCloseStarted!: () => void;
const nativeStarted = new Promise<void>((resolve) => {
nativeCloseStarted = resolve;
});
let releaseNativeClose!: () => void;
const nativeCloseGate = new Promise<void>((resolve) => {
releaseNativeClose = resolve;
});
let cleanupFailureObserved!: (failure: Error) => void;
const failureObserved = new Promise<Error>((resolve) => {
cleanupFailureObserved = resolve;
});
const artifact: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
artifactCloseAttempts += 1;
return artifactCloseAttempts === 1
? err(
new ProviderCleanupError(identity.id, ["artifact-reader"], {
reason: "artifact owner remains open",
}),
)
: { ok: true, value: null };
},
};
const native: AnalysisClient = {
execute: async () => ({
ok: true,
value: createAnalysisExecution(null, identity),
}),
close: async () => {
nativeCloseAttempts += 1;
if (nativeCloseAttempts === 1) {
nativeCloseStarted();
await nativeCloseGate;
}
return { ok: true, value: null };
},
};
const combined = combineBundleResourceClients(artifact, native);
const closing = combined.close({
onCleanupFailure: cleanupFailureObserved,
});
await Promise.all([nativeStarted, failureObserved]);
releaseNativeClose();
const first = await closing;
expect(first.ok).toBe(false);
if (first.ok) throw new Error("Expected mixed close failure");
expect(first.error.cleanupIncomplete).toBe(true);
expect(await combined.close()).toMatchObject({ ok: true });
expect(artifactCloseAttempts).toBe(2);
expect(nativeCloseAttempts).toBe(2);
});
@@ -0,0 +1,85 @@
import type {
AnalysisClient,
AnalysisOperation,
CapabilityDescriptor,
} from "../AnalysisProvider.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import { err, ok } from "../../domain/result.js";
import type { ResolvedAppBundleResourceRoot } from "../AppBundleExecutable.js";
import { closeAnalysisClient } from "../../providers/AnalysisClientCleanup.js";
export const BUNDLE_NATIVE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
"inspect_plist",
]);
export const BUNDLE_ARTIFACT_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
"decode_interface_builder",
"inspect_asset_catalog",
"inspect_keyed_archive",
]);
export const BUNDLE_RESOURCE_OPERATIONS = new Set<AnalysisOperation>([
...BUNDLE_NATIVE_RESOURCE_OPERATIONS,
...BUNDLE_ARTIFACT_RESOURCE_OPERATIONS,
]);
/** Provider-specific owner of the four read-only app-resource operations. */
export interface BundleResourceClientFactory {
createClient(
root: ResolvedAppBundleResourceRoot,
): BundleResourceClientBinding | Promise<BundleResourceClientBinding>;
}
export interface BundleResourceClientBinding {
readonly client: AnalysisClient;
readonly capabilities: readonly CapabilityDescriptor[];
}
export interface ActiveBundleResourceSession {
readonly root: ResolvedAppBundleResourceRoot;
readonly client: AnalysisClient;
readonly capabilities: readonly CapabilityDescriptor[];
readonly runId: string;
}
/** Route each resource read to the owner that understands its format. */
export const combineBundleResourceClients = (
artifactClient: AnalysisClient,
nativeClient: AnalysisClient,
): AnalysisClient => ({
execute: (operation, parameters, options) => {
if (operation === "health")
return artifactClient.execute(operation, parameters, options);
if (!BUNDLE_RESOURCE_OPERATIONS.has(operation))
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
operation,
"This session provides read-only app resource inspection only.",
),
),
);
return BUNDLE_NATIVE_RESOURCE_OPERATIONS.has(operation)
? nativeClient.execute(operation, parameters, options)
: artifactClient.execute(operation, parameters, options);
},
close: async (options) => {
const [artifact, native] = await Promise.all([
closeAnalysisClient(artifactClient, "bundle-resources", options),
closeAnalysisClient(nativeClient, "bundle-resources", options),
]);
if (!artifact.ok && !native.ok)
return err(
analysisErrorWithCleanupFailure(
artifact.error,
native.error,
"close_binary",
),
);
if (!artifact.ok) return artifact;
if (!native.ok) return native;
return ok(null);
},
});
@@ -1,4 +1,5 @@
import {
appendFile,
chmod,
mkdir,
readFile,
@@ -70,6 +71,58 @@ it("retains the admitted tarball descriptor and refuses a pathname replacement d
}
});
it("keeps tarball ownership when the admitted inode grows during output", async () => {
const root = await createTestTempDirectory("rea-tarball-growth-");
const files = await writeEnvironmentServeFiles(root);
const inputs = await readEnvironmentExportInputs(
files.manifestPath,
files.capturePath,
);
if (!inputs.ok) throw inputs.error;
const tarball = join(root, "package.tgz");
await writeFile(tarball, Buffer.alloc(2 * 1024 * 1024, 0x41));
const directory = join(root, "bundle");
const original = SafeOutputTree.prototype.write;
let grown = false;
const writer = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(async function (
this: SafeOutputTree,
...args: Parameters<SafeOutputTree["write"]>
) {
const [relative] = args;
if (!grown && relative === "envs/grafana-example/rea-agents.tgz") {
grown = true;
await appendFile(tarball, Buffer.alloc(1024 * 1024, 0x42));
}
return original.apply(this, args);
});
try {
const result = await exportEnvironmentBundle({
...inputs.value,
source: environmentServeSource(),
directory,
packageTarball: tarball,
});
expect(grown).toBe(true);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "AnalysisOutputError",
operation: "environment-export",
reason:
"Extracted content exceeds the inventoried size: envs/grafana-example/rea-agents.tgz",
},
});
if (result.ok)
throw new Error("Expected the changed tarball to be refused");
expect(result.error.cleanup).toBeUndefined();
expect(result.error.message).not.toContain("invalidated");
} finally {
writer.mockRestore();
}
});
it("uses confirmed publication after the output owner's descriptor cleanup completes", async () => {
const root = await createTestTempDirectory("rea-bundle-published-");
const files = await writeEnvironmentServeFiles(root);
@@ -10,6 +10,7 @@ import {
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactFileCleanupFailure } from "../../artifacts/ArtifactFileOperation.js";
import { RegularFileCleanupFailure } from "../../filesystem/OwnedRegularFile.js";
import { readFileHandleChunks } from "../../filesystem/readFileHandleChunks.js";
import { streamChunkToBuffer } from "../../artifacts/StreamBytes.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
@@ -51,10 +52,7 @@ const writeTarball = async (
): Promise<WrittenBundleFile> => {
const hash = createHash("sha256");
let observed = 0;
for await (const chunk of source.handle.createReadStream({
start: 0,
autoClose: false,
})) {
for await (const chunk of readFileHandleChunks(source.handle)) {
const bytes = streamChunkToBuffer(chunk);
observed += bytes.length;
if (observed > source.size) throw new RegularFileChangedError(source.path);
@@ -63,7 +61,7 @@ const writeTarball = async (
if (observed !== source.size) throw new RegularFileChangedError(source.path);
const written = await tree.write(
relative,
source.handle.createReadStream({ start: 0, autoClose: false }),
readFileHandleChunks(source.handle),
{
bytes: source.size,
sha256: hash.digest("hex"),
@@ -46,7 +46,7 @@ export class FirmwareAnalysisService {
const executed = await this.provider.execute(parsed.data, options);
if (!executed.ok) return executed;
const execution = executed.value;
if (execution.subject === null)
if (execution.subject == null)
return err(
new AnalysisOutputError(
operation,
@@ -82,7 +82,7 @@ export class JavaScriptRecoveryService {
const result = javascriptRecoveryResultSchema.safeParse(
execution.value.result,
);
if (!result.success || execution.value.subject === null)
if (!result.success || execution.value.subject == null)
return err(
new AnalysisOutputError(
OPERATION,
@@ -21,10 +21,22 @@ export const inspectNativeDispatch = async (
analysis: AnalysisOperationPort,
maxRecords: number,
signal?: AbortSignal,
objcClassImportAddresses?: readonly string[],
objcClassImportAddressesComplete?: boolean,
): EnhancedResult => {
const input = {
max_records: maxRecords,
...(objcClassImportAddresses === undefined
? {}
: {
objc_class_import_addresses: [...objcClassImportAddresses],
objc_class_import_addresses_complete:
objcClassImportAddressesComplete ?? true,
}),
};
const binaryMetadata = await analysis.execute(
NATIVE_DISPATCH_METADATA_SOURCES.binary,
{ max_records: maxRecords },
input,
signal === undefined ? {} : { signal },
);
if (binaryMetadata.ok)
@@ -59,20 +71,32 @@ export const inspectNativeDispatch = async (
"provider returned an invalid inventory",
),
);
const result = inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
);
if (objcClassImportAddresses !== undefined) {
result.objc_class_import_bindings = [];
result.objc_class_import_binding_coverage = {
facet: "objc_class_import_bindings",
status: "unsupported",
reason: "Native Mach-O pointer binding inspection is unavailable.",
examined: 0,
decoded: 0,
};
}
return ok(
jsonValueSchema.parse(
nativeDispatchMetadataResultSchema.parse({
target_sha256: execution.value.subject?.sha256 ?? null,
target_architecture: execution.value.subject?.architecture ?? null,
provider: execution.value.provider,
analysis_profile_digest:
execution.value.analysisProfile?.digest ?? null,
result: inspectNativeDispatchMetadata(
names.data.map((entry) => ({
address: entry.address,
name: "value" in entry ? entry.value : entry.name,
})),
maxRecords,
),
result,
}),
),
);
+1 -1
View File
@@ -113,7 +113,7 @@ export const traceNativeValues = async (
continue;
}
const parsed = functionDossierSchema.safeParse(execution.value.result);
if (!parsed.success || execution.value.subject === null)
if (!parsed.success || execution.value.subject == null)
return err(
new AnalysisOutputError(
"trace_native_values",
+10 -11
View File
@@ -238,17 +238,16 @@ const readJson = async (
operation: "capture_process_scenario" | "compare_process_captures",
signal?: AbortSignal,
): Promise<unknown> => {
if (signal?.aborted) throw new AnalysisCancelledError(operation);
const loaded = await readJsonFile(path, signal).catch((cause: unknown) => {
if (
signal?.aborted === true &&
!(cause instanceof RegularFileCleanupFailure)
)
throw new AnalysisCancelledError(operation, { cause });
throw cause;
});
if (signal?.aborted) throw new AnalysisCancelledError(operation);
if (loaded.ok) return loaded.value;
if (signal?.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const loaded = await readJsonFile(path, signal);
if (!loaded.ok && loaded.error instanceof AnalysisCancelledError)
throw new AnalysisCancelledError(operation, { cause: loaded.error.cause });
if (loaded.ok) {
if (signal?.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
return loaded.value;
}
if (loaded.error._tag !== "EvidenceFileError") throw loaded.error;
if (loaded.error.reason === "io") throw loaded.error;
const detail = describeValidationFailure(loaded.error.cause);
@@ -99,7 +99,7 @@ export class SqliteDatabaseService {
const report = sqliteDatabaseSchema.safeParse(execution.result);
if (
!report.success ||
execution.subject === null ||
execution.subject == null ||
!matchesSelectedDatabase(report.data, input.data, execution.subject)
)
return err(
+120 -1
View File
@@ -44,6 +44,9 @@ import { createEvidence } from "../domain/evidence.js";
import { freezeJsonSnapshot } from "../domain/immutableJson.js";
import { createArtifactInspection } from "../domain/artifactInspection.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
import type { BundleResourceRoot } from "../domain/apple/bundleResourceRoot.js";
import { BUNDLE_ARTIFACT_RESOURCE_OPERATIONS } from "../application/binary/BundleResourceSession.js";
/** Read-only inventory and exclusively owned extraction provider. */
export class ArtifactProvider implements AnalysisProvider {
@@ -71,6 +74,120 @@ export class ArtifactProvider implements AnalysisProvider {
createClient(target: BinaryTarget): AnalysisClient {
return new ArtifactClient(target, this.environment, this.#platform);
}
createBundleResourceClient(
root: ResolvedAppBundleResourceRoot,
): AnalysisClient {
return new BundleResourceArtifactClient(
root,
this.environment,
this.#platform,
);
}
}
const serializedResourceRoot = (
root: ResolvedAppBundleResourceRoot,
): BundleResourceRoot => ({
caller_path: root.callerPath,
canonical_path: root.canonicalPath,
info_plist_path: root.infoPlistPath,
layout: root.layout,
executable_sha256: root.executableSha256,
});
class BundleResourceArtifactClient implements AnalysisClient {
readonly #resourceScope = new ArtifactResourceScope();
constructor(
private readonly root: ResolvedAppBundleResourceRoot,
private readonly environment: Readonly<NodeJS.ProcessEnv>,
private readonly platform: NodeJS.Platform,
) {}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
if (operation === "health")
return ok(createAnalysisExecution(null, ARTIFACT_GRAPH_PROVIDER));
if (!BUNDLE_ARTIFACT_RESOURCE_OPERATIONS.has(operation))
return err(
new AnalysisCapabilityUnavailableError(
ARTIFACT_GRAPH_PROVIDER.id,
operation,
"The app resource reader supports only plist, Interface Builder, asset catalog, and keyed archive inspection.",
),
);
try {
const resourceRoot = serializedResourceRoot(this.root);
const result =
operation === "decode_interface_builder"
? await analyzeInterfaceBuilderBundle({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
limits: interfaceBuilderLimitsSchema.parse(parameters),
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: operation === "inspect_keyed_archive"
? await inspectBundleKeyedArchive({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
parameters,
platform: this.platform,
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: operation === "inspect_asset_catalog"
? await analyzeAppleAssetCatalogs({
bundlePath: this.root.canonicalPath,
targetSha256: null,
resourceRoot,
environment: this.environment,
page: parameters,
...(options?.signal === undefined
? {}
: { signal: options.signal }),
})
: (() => {
throw new AnalysisCapabilityUnavailableError(
ARTIFACT_GRAPH_PROVIDER.id,
operation,
"Property list inspection requires a platform-native reader.",
);
})();
return ok(
createAnalysisExecution(result, ARTIFACT_GRAPH_PROVIDER, {
limitations: "limitations" in result ? result.limitations : [],
}),
);
} catch (cause: unknown) {
return err(
translateArtifactFailure(operation as ArtifactAnalysisOperation, cause),
);
}
}
close(): Promise<Result<null, AnalysisError>> {
return this.#resourceScope.close().then(
() => ok(null),
(cause: unknown) =>
err(
new ProviderCleanupError(
ARTIFACT_GRAPH_PROVIDER.id,
["app bundle resource reader"],
{ reason: cause instanceof Error ? cause.message : String(cause) },
{ cause, operation: "close_binary" },
),
),
);
}
}
class ArtifactClient implements AnalysisClient {
@@ -225,7 +342,9 @@ class ArtifactClient implements AnalysisClient {
}),
);
} catch (cause: unknown) {
return err(translateArtifactFailure(operation, cause));
return err(
translateArtifactFailure(operation as ArtifactAnalysisOperation, cause),
);
}
}
@@ -0,0 +1,60 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "plist";
import { afterEach, expect, it } from "vitest";
import { ArtifactProvider } from "./ArtifactProvider.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
it("returns keyed archive evidence with root provenance and unknown executable identity", async () => {
const bundlePath = await mkdtemp(join(tmpdir(), "rea-resource-reader-"));
temporaryDirectories.push(bundlePath);
await mkdir(join(bundlePath, "Resources"));
await writeFile(
join(bundlePath, "Info.plist"),
'<?xml version="1.0"?><plist><dict/></plist>',
);
const archive = build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "sample"],
$top: { root: { UID: 1 } },
});
await writeFile(join(bundlePath, "Resources", "Archive.plist"), archive);
const root: ResolvedAppBundleResourceRoot = {
callerPath: bundlePath,
canonicalPath: bundlePath,
infoPlistPath: join(bundlePath, "Info.plist"),
layout: "root",
executableSha256: null,
};
const provider = new ArtifactProvider({}, "linux");
const client = provider.createBundleResourceClient(root);
const execution = await client.execute("inspect_keyed_archive", {
path: "Resources/Archive.plist",
});
expect(execution.ok).toBe(true);
if (execution.ok) {
expect(execution.value.result).toMatchObject({
target_sha256: null,
resource_root: {
caller_path: bundlePath,
canonical_path: bundlePath,
executable_sha256: null,
},
archive_path: "Resources/Archive.plist",
});
}
expect((await client.close()).ok).toBe(true);
});
@@ -21,7 +21,8 @@ const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
export const analyzeAppleAssetCatalogs = async (input: {
readonly bundlePath: string;
readonly targetSha256: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly page?: unknown;
readonly signal?: AbortSignal;
@@ -46,6 +47,9 @@ export const analyzeAppleAssetCatalogs = async (input: {
)({
bundlePath: input.bundlePath,
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
signal:
input.signal === undefined
? interfaceBuilderCancel.signal
@@ -71,6 +75,9 @@ export const analyzeAppleAssetCatalogs = async (input: {
);
const result = projectAppleAssetCatalogPage({
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
catalogs,
offset: page.offset,
limit: page.limit,
+22 -2
View File
@@ -1,6 +1,6 @@
import type { BigIntStats } from "node:fs";
import { lstat, readlink, realpath } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import { isAbsolute, join, relative, sep } from "node:path";
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
@@ -36,10 +36,25 @@ class FilesystemTreeView implements DylibTreeView {
constructor(
private readonly root: string,
private readonly sourceRoot: string,
private readonly files: DylibFileAccess,
private readonly signal?: AbortSignal,
) {}
rootRelativePath(absolutePath: string): string | undefined {
const roots = isAbsolute(this.sourceRoot)
? [this.root, this.sourceRoot]
: [this.root];
for (let root of roots) {
while (root.length > 1 && root.endsWith(sep)) root = root.slice(0, -1);
if (absolutePath === root) return "";
const prefix = root === sep ? sep : `${root}${sep}`;
if (absolutePath.startsWith(prefix))
return absolutePath.slice(prefix.length).split(sep).join("/");
}
return undefined;
}
entry(path: string): Promise<DylibTreeEntry | undefined> {
const cached = this.#entries.get(path);
if (cached !== undefined) return cached;
@@ -113,7 +128,12 @@ export const traceDylibResolution = async (options: {
.split(sep)
.join("/");
assertArtifactReadActive(options.signal);
const view = new FilesystemTreeView(root, files, options.signal);
const view = new FilesystemTreeView(
root,
options.rootPath,
files,
options.signal,
);
const { roots: requested, unclassified } =
parsed.data.roots !== undefined
? { roots: parsed.data.roots, unclassified: [] }
@@ -55,7 +55,8 @@ const MAX_REPORTED_OMITTED_ARCHIVES = 64;
/** Decode compiled Interface Builder archives from a local app bundle. */
export const analyzeInterfaceBuilderBundle = async (input: {
readonly bundlePath: string;
readonly targetSha256: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
readonly limits?: unknown;
readonly signal?: AbortSignal;
}) => {
@@ -205,6 +206,9 @@ export const analyzeInterfaceBuilderBundle = async (input: {
}
const result = buildInterfaceBuilderAnalysis({
targetSha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resourceRoot: input.resourceRoot }),
toolVersion: "rea-interface-builder/1",
documents,
limits,
+5 -1
View File
@@ -152,7 +152,8 @@ const keyedArchiveKindRemediation = (
/** Read exactly one regular, contained bundle entry without following symlinks. */
export const inspectBundleKeyedArchive = async (input: {
bundlePath: string;
targetSha256: string;
targetSha256: string | null;
resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
parameters: unknown;
signal?: AbortSignal;
/** Host whose available workflows the remediation names. */
@@ -228,6 +229,9 @@ export const inspectBundleKeyedArchive = async (input: {
try {
return keyedArchiveResultSchema.parse({
target_sha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resource_root: input.resourceRoot }),
archive_path: entry.path,
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
...decodeKeyedArchiveBytes(bytes, selected),
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { mkdir, realpath, rename, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -142,6 +142,241 @@ it("uses legacy simulator and device platform facts when resolving dylibs", asyn
);
});
describe("absolute dyld candidate paths at the filesystem reader boundary", () => {
it.each(["install-name", "rpath"] as const)(
"resolves an absolute in-root %s while preserving its source path",
async (kind) => {
const root = await createTestTempDirectory("rea-dylib-absolute-in-root-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/libBridge.dylib";
const leafPath = "Frameworks/libLeaf.dylib";
const bridgeAbsolute = `${canonicalRoot}/${bridgePath}`;
const leafName = "@loader_path/libLeaf.dylib";
const executable = machoImage({
commands: [
buildVersionCommand(1),
...(kind === "rpath"
? [rpathCommand(`${canonicalRoot}/Frameworks`)]
: []),
dylibCommand(
LC.LOAD_DYLIB,
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, leafName),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(bridgeAbsolute, bridge);
await writeFile(join(root, leafPath), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name:
kind === "rpath" ? "@rpath/libBridge.dylib" : bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
...(kind === "rpath"
? { rpath: `${canonicalRoot}/Frameworks` }
: {}),
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
install_name: leafName,
resolution: { status: "resolved", image: leafPath },
}),
);
expect(trace.images.map(({ path }) => path)).toEqual([
"App",
bridgePath,
leafPath,
]);
},
);
it("keeps a genuinely external absolute candidate outside the analyzed root", async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-outside-");
const canonicalRoot = await realpath(root);
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib"),
dylibCommand(LC.LOAD_DYLIB, `${canonicalRoot}/../outside.dylib`),
dylibCommand(
LC.LOAD_DYLIB,
`file://${canonicalRoot}/Frameworks/libBridge.dylib`,
),
],
});
await writeFile(join(root, "App"), executable);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: "/usr/lib/libSystem.B.dylib",
outcome: "outside-target",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[1]).toMatchObject({
candidates: [{ outcome: "escapes-target", resolved_path: null }],
resolution: { status: "undetermined", image: null },
});
expect(trace.edges[2]).toMatchObject({
install_name: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
candidates: [
{
path: `file://${canonicalRoot}/Frameworks/libBridge.dylib`,
outcome: "undetermined",
resolved_path: null,
},
],
resolution: { status: "undetermined", image: null },
});
expect(trace.images.map(({ path }) => path)).toEqual(["App"]);
});
});
describe("absolute dyld candidate traversal", () => {
it("maps an absolute candidate spelled through the selected root symlink", async () => {
const root = await createTestTempDirectory(
"rea-dylib-absolute-root-alias-",
);
const aliasRoot = join(root, "bundle-alias");
const bridgePath = "Frameworks/libBridge.dylib";
const bridgeAbsolute = `${aliasRoot}/${bridgePath}`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, bridgeAbsolute),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await symlink(".", aliasRoot);
await mkdir(join(root, "Frameworks"), { recursive: true });
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
const trace = await traceDylibResolution({
rootPath: aliasRoot,
targetPath: `${aliasRoot}/App`,
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
install_name: bridgeAbsolute,
candidates: [
{
path: bridgeAbsolute,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
});
it.skipIf(process.platform === "win32")(
"preserves symlink-then-parent traversal for an absolute in-root path",
async () => {
const root = await createTestTempDirectory("rea-dylib-absolute-symlink-");
const canonicalRoot = await realpath(root);
const bridgePath = "Frameworks/Versions/libBridge.dylib";
const decoyPath = "Frameworks/libBridge.dylib";
const sourcePath = `${canonicalRoot}/Frameworks/Current/../libBridge.dylib`;
const executable = machoImage({
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, sourcePath),
],
});
const bridge = machoImage({
fileType: FILE_TYPE.dylib,
commands: [
buildVersionCommand(1),
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libLeaf.dylib"),
],
});
const leaf = machoImage({
fileType: FILE_TYPE.dylib,
commands: [buildVersionCommand(1)],
});
await mkdir(join(root, "Frameworks/Versions/A"), { recursive: true });
await symlink("Versions/A", join(root, "Frameworks/Current"));
await writeFile(join(root, "App"), executable);
await writeFile(join(root, bridgePath), bridge);
await writeFile(join(root, decoyPath), new Uint8Array([0, 1, 2]));
await writeFile(join(root, "Frameworks/Versions/libLeaf.dylib"), leaf);
const trace = await traceDylibResolution({
rootPath: root,
targetPath: join(root, "App"),
targetSha256: createHash("sha256").update(executable).digest("hex"),
enumerateRoots: false,
parameters: { roots: ["App"] },
});
expect(trace.edges[0]).toMatchObject({
candidates: [
{
path: sourcePath,
outcome: "resolved",
resolved_path: bridgePath,
},
],
resolution: { status: "resolved", image: bridgePath },
});
expect(trace.edges).toContainEqual(
expect.objectContaining({
loader: bridgePath,
resolution: {
status: "resolved",
image: "Frameworks/Versions/libLeaf.dylib",
},
}),
);
},
);
});
it("selects matching arm64e subtype slices when tracing a mixed FAT image", async () => {
const root = await createTestTempDirectory("rea-macho-arm64e-subtypes-");
const executablePath = "Contents/MacOS/App";
+53 -18
View File
@@ -25,7 +25,7 @@ import {
initiatorLocation,
integerOrNull,
isJsonMediaType,
isMainFrameNavigation,
cdpFrameIdentity,
} from "./CdpCaptureEventHelpers.js";
import type { CdpCaptureEventsState } from "./CdpCaptureEventState.js";
import {
@@ -509,28 +509,41 @@ export const handleWebSocketFrame = (
const connection = state.webSocketConnectionsById.get(requestId);
if (connection === undefined) return;
const response = recordValue(params.response);
const payload = cdpStringValue(response?.payloadData) ?? "";
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
const decodedBytes =
opcode === 1 ? undefined : paddedBase64ByteLength(payload);
if (opcode !== 1 && decodedBytes === undefined)
const opcodeValue = numberValue(response?.opcode);
// RFC 6455 encodes opcodes in four bits; keep reserved values as observed data.
const opcode =
opcodeValue !== undefined &&
Number.isInteger(opcodeValue) &&
opcodeValue >= 0 &&
opcodeValue <= 15
? opcodeValue
: null;
const payload = cdpStringValue(response?.payloadData);
const payloadBytes =
opcode === null || payload === undefined
? null
: opcode === 1
? Buffer.byteLength(payload)
: (paddedBase64ByteLength(payload) ?? null);
if (opcode === null || payloadBytes === null)
state.completeness.exclude("websocket_frames", "invalid_protocol_value");
connection.events.push({
direction,
opcode,
payload_bytes:
opcode === 1 ? Buffer.byteLength(payload) : (decodedBytes ?? null),
payload_bytes: payloadBytes,
payload_shape: captureWebSocketShape(state, payload, opcode),
});
};
const captureWebSocketShape = (
state: CdpCaptureEventsState,
payload: string,
opcode: number,
payload: string | undefined,
opcode: number | null,
): WebPageInspection["network"]["websocket_connections"][number]["events"][number]["payload_shape"] => {
if (!state.input.include_websocket_shapes) return null;
if (opcode === null) return null;
if (opcode !== 1) return { format: "binary", json_shape: null };
if (payload === undefined) return null;
const shape = inferJsonShape(payload);
return {
format: shape === null ? "text" : "json",
@@ -594,25 +607,47 @@ export const handleWebSocketCreated = (
export const handleFrameNavigated = (
state: CdpCaptureEventsState,
params: UnknownRecord,
params: unknown,
): void => {
const frame = recordValue(params.frame);
if (state.mainFrameId === undefined) return;
if (
!isMainFrameNavigation(
{ method: "Page.frameNavigated", params },
state.mainFrameId,
const identity = cdpFrameIdentity(
{ method: "Page.frameNavigated", params },
state.mainFrameId,
);
if (identity.identityIssue !== null || identity.parentIssue !== null) {
state.completeness.exclude("frames", "invalid_protocol_value");
if (
identity.selection === "unknown" &&
identity.parentRelation === "child" &&
identity.identityIssue !== null
)
)
return;
state.unknownRootIdentity = true;
return;
}
if (identity.selection === "other" && identity.parentRelation === "child")
return;
if (identity.selection === "unknown") {
state.completeness.exclude("frames", "invalid_protocol_value");
state.unknownRootIdentity = true;
return;
}
if (identity.selection === "other" && !identity.isRootReplacement) {
state.completeness.exclude("frames", "invalid_protocol_value");
state.unknownRootIdentity = true;
return;
}
const frame = recordValue(recordValue(params)?.frame);
const rawUrl = cdpStringValue(frame?.url);
const loaderId = cdpStringValue(frame?.loaderId);
// `frameNavigated` re-fires for a document already reported (same URL and
// loader). Treating that as a navigation aborted a valid capture with
// `target_changed`, which is a false claim that the target moved.
const document = `${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`;
const document = `${String(identity.frameId ?? "")}\0${String(loaderId ?? "")}\0${String(rawUrl ?? "")}`;
if (state.committedDocument === document) return;
state.committedDocument = document;
if (identity.isRootReplacement)
state.mainFrameId = identity.frameId ?? undefined;
state.navigationDuringCapture = true;
if (
isHttpUrl(rawUrl) &&
+102
View File
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js";
describe("CDP frame identity ownership", () => {
it.each([
["missing", {}],
["null", { frame: null }],
["non-object", { frame: "frame" }],
])("does not infer a root from a %s frame object", (_label, params) => {
expect(
cdpFrameIdentity(
{ method: "Page.frameNavigated", params },
"selected-main",
),
).toMatchObject({
applicability: "applicable",
selection: "unknown",
isRootReplacement: false,
identityIssue: "missing_frame",
parentRelation: "unknown",
parentId: null,
parentIssue: null,
});
});
it("recognizes a valid frame with no parent property as a root", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "replacement-main" } },
},
"selected-main",
),
).toMatchObject({
applicability: "applicable",
selection: "other",
isRootReplacement: true,
identityIssue: null,
parentRelation: "root",
parentId: null,
parentIssue: null,
});
});
it("does not interpret a malformed parent value as root evidence", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "replacement-main", parentId: "" } },
},
"selected-main",
),
).toMatchObject({
selection: "other",
isRootReplacement: false,
identityIssue: null,
parentRelation: "unknown",
parentId: null,
parentIssue: "invalid_parent_id",
});
});
it("preserves a self-parented frame ID but leaves its parent relation unknown", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: {
frame: { id: "candidate-root", parentId: "candidate-root" },
},
},
"selected-main",
),
).toMatchObject({
selection: "other",
isRootReplacement: false,
frameId: "candidate-root",
identityIssue: null,
parentRelation: "unknown",
parentId: null,
parentIssue: "self_parent",
});
});
it("retains the selected-main parent issue for a self-parented selected frame", () => {
expect(
cdpFrameIdentity(
{
method: "Page.frameNavigated",
params: { frame: { id: "selected-main", parentId: "selected-main" } },
},
"selected-main",
),
).toMatchObject({
selection: "main",
parentRelation: "unknown",
parentIssue: "main_frame_has_parent",
});
});
});
+123 -12
View File
@@ -8,21 +8,132 @@ import {
} from "./CdpCaptureValues.js";
import type { CdpEvent } from "./CdpConnection.js";
/** Identify a top-level navigation event from its untrusted CDP envelope. */
export const isMainFrameNavigation = (
export type FrameSelection = "main" | "other" | "unknown";
export type FrameParentRelation =
| "root"
| "child"
| "unknown"
| "not_applicable";
/** Normalized frame identity facts shared by all selected-frame consumers. */
export interface CdpFrameIdentity {
readonly applicability: "applicable" | "not_applicable";
readonly selection: FrameSelection;
readonly isRootReplacement: boolean;
readonly frameId: string | null;
readonly identityIssue:
| "missing_params"
| "invalid_params"
| "missing_frame"
| "missing_id"
| "invalid_id"
| null;
readonly parentRelation: FrameParentRelation;
readonly parentId: string | null;
readonly parentIssue:
| "invalid_parent_id"
| "self_parent"
| "main_frame_has_parent"
| null;
}
const nonEmptyString = (value: unknown): string | undefined =>
typeof value === "string" && value.length > 0 ? value : undefined;
const identityIssueFor = (
navigated: boolean,
frame: UnknownRecord | undefined,
rawId: unknown,
): CdpFrameIdentity["identityIssue"] => {
if (navigated && frame === undefined) return "missing_frame";
if (rawId === undefined) return "missing_id";
return nonEmptyString(rawId) === undefined ? "invalid_id" : null;
};
const parentFacts = (
navigated: boolean,
frame: UnknownRecord | undefined,
): Pick<CdpFrameIdentity, "parentRelation" | "parentId" | "parentIssue"> => {
if (!navigated)
return {
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
if (frame === undefined)
return { parentRelation: "unknown", parentId: null, parentIssue: null };
if (!("parentId" in frame))
return { parentRelation: "root", parentId: null, parentIssue: null };
const parentId = nonEmptyString(frame.parentId);
return parentId === undefined || parentId === nonEmptyString(frame.id)
? {
parentRelation: "unknown",
parentId: null,
parentIssue:
parentId === undefined ? "invalid_parent_id" : "self_parent",
}
: { parentRelation: "child", parentId, parentIssue: null };
};
/** Parse required frame identity separately from the optional parent fact. */
export const cdpFrameIdentity = (
event: CdpEvent,
mainFrameId: string,
): boolean => {
): CdpFrameIdentity => {
const navigated = event.method === "Page.frameNavigated";
const requested = event.method === "Page.frameRequestedNavigation";
const sameDocument = event.method === "Page.navigatedWithinDocument";
if (!navigated && !requested && !sameDocument)
return {
applicability: "not_applicable",
selection: "unknown",
isRootReplacement: false,
frameId: null,
identityIssue: null,
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
const params = recordValue(event.params);
if (params === undefined) return false;
const frameId =
event.method === "Page.frameNavigated"
? cdpStringValue(recordValue(params.frame)?.id)
: event.method === "Page.frameRequestedNavigation" ||
event.method === "Page.navigatedWithinDocument"
? cdpStringValue(params.frameId)
: undefined;
return frameId === mainFrameId;
if (params === undefined)
return {
applicability: "applicable",
selection: "unknown",
isRootReplacement: false,
frameId: null,
identityIssue:
event.params === undefined ? "missing_params" : "invalid_params",
parentRelation: "not_applicable",
parentId: null,
parentIssue: null,
};
const frame = navigated ? recordValue(params.frame) : undefined;
const rawId = navigated ? frame?.id : params.frameId;
const frameId = nonEmptyString(rawId);
const selection =
frameId === undefined
? "unknown"
: frameId === mainFrameId
? "main"
: "other";
const identityIssue = identityIssueFor(navigated, frame, rawId);
const parent = parentFacts(navigated, frame);
return {
applicability: "applicable",
selection,
isRootReplacement:
selection === "other" && parent.parentRelation === "root",
frameId: frameId ?? null,
identityIssue,
parentRelation: parent.parentRelation,
parentId: parent.parentId,
parentIssue:
selection === "main" &&
(parent.parentRelation === "child" ||
parent.parentIssue === "self_parent")
? "main_frame_has_parent"
: parent.parentIssue,
};
};
export const firstCallFrame = (
+1
View File
@@ -26,6 +26,7 @@ export interface CdpCaptureEventsState {
readonly completeness: CdpCaptureCompleteness;
originViolation: boolean;
navigationDuringCapture: boolean;
unknownRootIdentity: boolean;
mainFrameId: string | undefined;
/**
* Identity of the document last observed in the main frame. `frameNavigated`
+6 -3
View File
@@ -45,6 +45,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
]);
originViolation = false;
navigationDuringCapture = false;
unknownRootIdentity = false;
mainFrameId: string | undefined = undefined;
committedDocument: string | undefined = undefined;
@@ -60,6 +61,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
this.mainFrameId = frameId;
this.originViolation = false;
this.navigationDuringCapture = false;
this.unknownRootIdentity = false;
this.committedDocument = undefined;
this.scripts.clear();
this.scriptMetadataBudgetOmissionsById.clear();
@@ -122,6 +124,10 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
}
ingest(event: CdpEvent): void {
if (event.method === "Page.frameNavigated") {
handlers.handleFrameNavigated(this, event.params);
return;
}
const params = recordValue(event.params);
if (params === undefined) return;
switch (event.method) {
@@ -158,9 +164,6 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
case "Runtime.consoleAPICalled":
handlers.handleConsoleAPICalled(this, params);
break;
case "Page.frameNavigated":
handlers.handleFrameNavigated(this, params);
break;
case "Network.webSocketCreated":
handlers.handleWebSocketCreated(this, params);
break;
@@ -0,0 +1,182 @@
import { expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
const origin = "https://example.test";
const createEvents = (): CdpCaptureEvents =>
new CdpCaptureEvents(
inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: [origin],
target_id: "page",
include_websocket_shapes: true,
}),
new Set([origin]),
);
const captureFrame = (
opcode: unknown,
payloadData: unknown,
includeOpcode = true,
includePayload = true,
) => {
const events = createEvents();
events.ingest({
method: "Network.webSocketCreated",
params: { requestId: "socket", url: "wss://example.test/live" },
});
events.ingest({
method: "Network.webSocketFrameReceived",
params: {
requestId: "socket",
response: {
...(includeOpcode ? { opcode } : {}),
...(includePayload ? { payloadData } : {}),
},
},
});
return {
events,
frame: events.webSocketConnectionsById.get("socket")?.events[0],
};
};
it.each([
{
label: "missing",
opcode: undefined,
payload: "",
hasOpcode: false,
hasPayload: true,
},
{
label: "wrong type",
opcode: "1",
payload: "text",
hasOpcode: true,
hasPayload: true,
},
{
label: "explicit null",
opcode: null,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "negative",
opcode: -1,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "fractional",
opcode: 1.5,
payload: "text",
hasOpcode: true,
hasPayload: true,
},
{
label: "above opcode width",
opcode: 16,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "non-finite",
opcode: Number.NaN,
payload: "",
hasOpcode: true,
hasPayload: true,
},
] as const)(
"keeps $label WebSocket opcode unknown",
({ opcode, payload, hasOpcode, hasPayload }) => {
const { events, frame } = captureFrame(
opcode,
payload,
hasOpcode,
hasPayload,
);
expect(frame).toEqual({
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
});
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 1,
});
expect(
webPageInspectionSchema.shape.network.shape.websocket_connections.element.safeParse(
{
request_id: "socket",
url: "wss://example.test/live",
events: [frame],
},
).success,
).toBe(true);
},
);
it.each([
{ label: "continuation", opcode: 0, payload: "", bytes: 0, format: "binary" },
{ label: "empty text", opcode: 1, payload: "", bytes: 0, format: "text" },
{
label: "reserved four-bit opcode",
opcode: 15,
payload: "",
bytes: 0,
format: "binary",
},
] as const)(
"preserves observed $label data",
({ opcode, payload, bytes, format }) => {
const { events, frame } = captureFrame(opcode, payload);
expect(frame).toEqual({
direction: "received",
opcode,
payload_bytes: bytes,
payload_shape: { format, json_shape: null },
});
expect(events.completeness.snapshot().excluded).not.toContainEqual(
expect.objectContaining({
section: "websocket_frames",
reason: "invalid_protocol_value",
}),
);
},
);
it.each([
{
label: "missing payload",
opcode: 1,
payload: undefined,
hasPayload: false,
},
{ label: "wrong-type payload", opcode: 1, payload: 42, hasPayload: true },
] as const)(
"does not fabricate an empty payload for $label",
({ opcode, payload, hasPayload }) => {
const { events, frame } = captureFrame(opcode, payload, true, hasPayload);
expect(frame).toEqual({
direction: "received",
opcode,
payload_bytes: null,
payload_shape: null,
});
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 1,
});
},
);
+32 -7
View File
@@ -25,7 +25,7 @@ import {
type UnknownRecord,
} from "./CdpCaptureValues.js";
import { mainFrameUrl } from "./CdpCaptureDocuments.js";
import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js";
import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js";
import { cdpTargetEventMatches } from "./CdpTargetEvents.js";
interface ObservationContext {
@@ -184,7 +184,7 @@ class TimelineCapture {
constructor(
private readonly allowedOrigins: ReadonlySet<string>,
private readonly mainFrameId: string,
private mainFrameId: string,
initialUrl: string,
) {
this.finalUrl =
@@ -204,7 +204,15 @@ class TimelineCapture {
#ingestArmed(event: CdpTransportEvent, bufferedBeforeArm = false): void {
if (this.#endReason !== undefined || this.#scopeExcluded) return;
const params = recordValue(event.params);
if (params === undefined) return;
if (params === undefined) {
if (
event.method === "Page.frameRequestedNavigation" ||
event.method === "Page.frameNavigated" ||
event.method === "Page.navigatedWithinDocument"
)
this.completeness.exclude("timeline", "invalid_protocol_value");
return;
}
const contextOnly =
bufferedBeforeArm && hasNoComparableProducerTimestamp(event.method);
if (contextOnly) this.#hasUnknownEventTime = true;
@@ -326,7 +334,10 @@ class TimelineCapture {
params: UnknownRecord,
contextOnly: boolean,
): void {
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const identity = cdpFrameIdentity(event, this.mainFrameId);
if (identity.identityIssue !== null || identity.parentIssue !== null)
this.completeness.exclude("timeline", "invalid_protocol_value");
if (identity.selection !== "main") return;
const reason = safeDetail(params.reason);
const rawUrl = cdpStringValue(params.url);
const destination =
@@ -349,12 +360,23 @@ class TimelineCapture {
contextOnly: boolean,
): void {
const frame = recordValue(params.frame);
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const identity = cdpFrameIdentity(event, this.mainFrameId);
if (identity.identityIssue !== null || identity.parentIssue !== null)
this.completeness.exclude("timeline", "invalid_protocol_value");
if (identity.selection !== "main" && !identity.isRootReplacement) return;
const rawUrl = cdpStringValue(frame?.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
if (identity.isRootReplacement) {
this.mainFrameId = identity.frameId ?? this.mainFrameId;
this.#pendingReload = false;
if (contextOnly) {
this.#stopIfOutOfScope(destination);
return;
}
}
this.#add({
type:
!contextOnly && this.#pendingReload
!identity.isRootReplacement && !contextOnly && this.#pendingReload
? "same_origin_reload"
: "navigation_committed",
params: { ...params, frameId: frame?.id, loaderId: frame?.loaderId },
@@ -374,7 +396,10 @@ class TimelineCapture {
params: UnknownRecord,
contextOnly: boolean,
): void {
if (!isMainFrameNavigation(event, this.mainFrameId)) return;
const identity = cdpFrameIdentity(event, this.mainFrameId);
if (identity.identityIssue !== null || identity.parentIssue !== null)
this.completeness.exclude("timeline", "invalid_protocol_value");
if (identity.selection !== "main") return;
const rawUrl = cdpStringValue(params.url);
const destination = scopedUrl(rawUrl, this.allowedOrigins);
this.#add({
+4
View File
@@ -170,6 +170,8 @@ const captureAuthorizedPage = async (
operation: context.operation,
});
const completedUrl = mainFrameUrl(completedFrameResult) ?? "";
if (state.events.unknownRootIdentity)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
if (state.events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (state.events.navigationDuringCapture || completedUrl !== attachedUrl)
@@ -264,6 +266,8 @@ const authorizeObservationWindow = async (
context.operation,
signal,
);
if (events.unknownRootIdentity)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
if (events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
};
+61 -15
View File
@@ -5,7 +5,7 @@ import type {
} from "../domain/webScreenshot.js";
import { createWebScreenshotArtifact } from "../domain/webScreenshot.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { isMainFrameNavigation } from "./CdpCaptureEventHelpers.js";
import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
import type { CdpConnection, CdpEvent } from "./CdpConnection.js";
import {
@@ -63,12 +63,15 @@ export const captureCdpScreenshot = async (
const navigation = {
changed: false,
leftScope: false,
invalidIdentity: false,
frameId: mainFrame.frame_id,
url: beforeUrl,
loaderId: cdpStringValue(initialFrame?.loaderId),
completeness: new CdpCaptureCompleteness(),
};
const removeListener = context.connection.onEvent((event) => {
if (event.sessionId !== context.sessionId) return;
observeScreenshotNavigation(event, mainFrame.frame_id, origins, navigation);
observeScreenshotNavigation(event, origins, navigation);
});
let result: ReturnType<typeof recordValue>;
let after: unknown;
@@ -90,13 +93,10 @@ export const captureCdpScreenshot = async (
} finally {
removeListener();
}
const encoded = cdpStringValue(result?.data);
const bytes =
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
if (bytes === undefined)
const screenshot = decodeScreenshot(result);
if (screenshot === undefined)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const dimensions = readPngHeaderDimensions(bytes);
if (dimensions === undefined)
if (navigation.invalidIdentity)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
if (navigation.leftScope)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
@@ -122,9 +122,9 @@ export const captureCdpScreenshot = async (
origin: authorized.origin ?? "",
},
captured_at: new Date().toISOString(),
viewport: dimensions,
artifact: createWebScreenshotArtifact(bytes),
completeness: new CdpCaptureCompleteness().snapshot(),
viewport: screenshot.dimensions,
artifact: createWebScreenshotArtifact(screenshot.bytes),
completeness: navigation.completeness.snapshot(),
limitations: [
"The screenshot contains the visible viewport, including on-screen content.",
"REA does not scroll, evaluate JavaScript, or capture beyond the current viewport.",
@@ -132,24 +132,62 @@ export const captureCdpScreenshot = async (
};
};
const decodeScreenshot = (
result: ReturnType<typeof recordValue>,
):
| {
readonly bytes: Buffer;
readonly dimensions: { width: number; height: number };
}
| undefined => {
const encoded = cdpStringValue(result?.data);
const bytes =
encoded === undefined ? undefined : decodeCanonicalBase64(encoded);
if (bytes === undefined) return undefined;
const dimensions = readPngHeaderDimensions(bytes);
return dimensions === undefined ? undefined : { bytes, dimensions };
};
const observeScreenshotNavigation = (
event: CdpEvent,
mainFrameId: string,
origins: ReadonlySet<string>,
state: {
changed: boolean;
leftScope: boolean;
readonly url: string | undefined;
readonly loaderId: string | undefined;
invalidIdentity: boolean;
frameId: string;
url: string | undefined;
loaderId: string | undefined;
readonly completeness: CdpCaptureCompleteness;
},
): void => {
if (!isMainFrameNavigation(event, mainFrameId)) return;
const identity = cdpFrameIdentity(event, state.frameId);
if (identity.applicability === "not_applicable") return;
if (identity.identityIssue !== null || identity.parentIssue !== null) {
state.completeness.exclude("screenshot", "invalid_protocol_value");
if (
identity.selection === "unknown" &&
identity.parentRelation === "child" &&
identity.identityIssue !== null
)
return;
state.invalidIdentity = true;
return;
}
if (identity.selection === "unknown") {
if (identity.parentRelation === "child") return;
state.completeness.exclude("screenshot", "invalid_protocol_value");
state.invalidIdentity = true;
return;
}
if (identity.selection === "other" && !identity.isRootReplacement) return;
const params = recordValue(event.params);
if (params === undefined) return;
const frame =
event.method === "Page.frameNavigated" ? recordValue(params.frame) : params;
if (
event.method === "Page.frameNavigated" &&
!identity.isRootReplacement &&
state.loaderId !== undefined &&
state.loaderId !== "" &&
cdpStringValue(frame?.loaderId) === state.loaderId &&
@@ -160,4 +198,12 @@ const observeScreenshotNavigation = (
state.changed = true;
if (allowedSanitizedUrl(frame?.url, origins) === undefined)
state.leftScope = true;
if (event.method === "Page.frameNavigated") {
if (identity.isRootReplacement)
state.frameId = identity.frameId ?? state.frameId;
state.url = cdpStringValue(frame?.url) ?? state.url;
state.loaderId = cdpStringValue(frame?.loaderId);
} else if (event.method === "Page.navigatedWithinDocument") {
state.url = cdpStringValue(frame?.url) ?? state.url;
}
};
+9 -105
View File
@@ -1,6 +1,6 @@
import { expect, it } from "vitest";
import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
@@ -13,7 +13,6 @@ import type {
} from "../domain/webBundleAnalysis.js";
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok } from "../domain/result.js";
import {
@@ -373,107 +372,12 @@ const deferred = <T>(): {
};
const inspection = (sources: readonly string[]) =>
webPageInspectionSchema.parse({
browser: {
product: "Fake Chrome",
protocol_version: "1.3",
revision: "1",
user_agent: "fake",
js_version: "1",
},
target: {
target_id: "page-1",
type: "page",
title: "App",
url: `${origin}/app`,
createWebPageInspection(
sources.map((source, index) => ({
source,
scriptKey: `scr_${String(index + 1).repeat(64)}`,
url: `${origin}/assets/${String(index)}.js`,
origin,
attached: false,
},
capture_window: {
started_at: "2026-07-14T00:00:00.000Z",
ended_at: "2026-07-14T00:00:01.000Z",
observation_ms: 1_000,
},
completeness: {
status: "attach_limited",
conditions: ["attach_limited"],
policy_filtered_sections: [],
attach_limited_sections: ["network_requests"],
truncated_sections: [],
unavailable_sections: [],
excluded: [],
dropped_events: {
scripts: 0,
network_requests: 0,
console_events: 0,
websocket_connections: 0,
websocket_frames: 0,
webmcp_tools: 0,
timeline_events: 0,
total: 0,
},
},
frames: [],
dom: { total_nodes: 0, nodes: [] },
accessibility: {
total_nodes: 0,
text_capture: {
status: "not_approved",
retained_bytes: 0,
excluded_fields: 0,
},
nodes: [],
},
scripts: {
total: sources.length,
items: sources.map((source, index) => ({
script_key: `scr_${String(index + 1).repeat(64)}`,
url: `${origin}/assets/${String(index)}.js`,
origin,
cdp_hash: `hash-${String(index)}`,
length: Buffer.byteLength(source),
is_module: true,
language: "JavaScript",
source_map_url: null,
resource_reconciliation: {
status: "unmatched",
reason: "no_exact_sanitized_url",
},
source: {
included: true,
artifact: createWebTextArtifact(source, "text/javascript"),
},
})),
},
resources: [],
network: {
requests: [],
websocket_connections: [],
coverage_started_at: "2026-07-14T00:00:00.000Z",
prior_activity_available: false,
},
console: {
events: [],
coverage_started_at: "2026-07-14T00:00:00.000Z",
prior_activity_available: false,
},
workers: [],
metadata: {
responses: [],
dom_urls: [],
agent_hints: [],
excluded_dom_urls: 0,
headers_allowlisted: true,
},
storage: {
origin,
usage_bytes: null,
quota_bytes: null,
local_storage_keys: [],
session_storage_keys: [],
indexed_db_names: [],
cache_names: [],
values_redacted: true,
},
limitations: [],
});
cdpHash: `hash-${String(index)}`,
})),
);
+79 -97
View File
@@ -216,6 +216,73 @@ const policyResponse = (method: string): unknown =>
? { result: { value: null } }
: {};
const prepareHeldPolicyRestoration = async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
const { promise: transportClosed, resolve: signalTransportClosed } =
deferred();
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
return {
cleanup,
page,
storage,
cancellation,
session,
finishRestoration,
started,
transportClosed,
};
};
const prepareLateStopRetry = async () => {
const {
cleanup,
page,
cancellation,
session,
finishRestoration,
started,
transportClosed,
} = await prepareHeldPolicyRestoration();
vi.useFakeTimers();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
return { cleanup, page, session };
} catch (cause) {
vi.useRealTimers();
throw cause;
}
};
beforeEach(() => {
vi.clearAllMocks();
mocks.policySend.mockReset();
@@ -372,29 +439,16 @@ it("transfers a late stop request after storage has already released", async ()
it.each(["pending", "settled"] as const)(
"honors a late stop after policy restoration is %s",
async (phase) => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
const { promise: transportClosed, resolve: signalTransportClosed } =
deferred();
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
const {
cleanup,
page,
storage,
cancellation,
session,
finishRestoration,
started,
transportClosed,
} = await prepareHeldPolicyRestoration();
vi.useFakeTimers();
try {
@@ -661,44 +715,8 @@ it("retries a failed connected-page stop when service workers are allowed", asyn
});
it("retains a late fallback stop failure for retry", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
const { promise: transportClosed, resolve: signalTransportClosed } =
deferred();
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
vi.useFakeTimers();
const { cleanup, page, session } = await prepareLateStopRetry();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
const stopFailure = new Error("late main-frame stop failed");
const detachedFrame = {
evaluate: vi.fn(async () => {
@@ -734,44 +752,8 @@ it("retains a late fallback stop failure for retry", async () => {
});
it("coalesces concurrent late-stop close retries", async () => {
const { cleanup, page, storage } = fixture();
const cancellation = new AbortController();
storage?.close.mockResolvedValue({ loadingStopped: false });
const attached = connectedScenario("block");
const session = await PlaywrightScenarioSession.open(
attached,
{},
{ signal: cancellation.signal },
);
const { promise: held, resolve: finishRestoration } = deferred();
const { promise: started, resolve: restorationStarted } = deferred();
const { promise: transportClosed, resolve: signalTransportClosed } =
deferred();
mocks.policyTransportClose.mockImplementation(async () => {
signalTransportClosed();
});
mocks.policySend.mockImplementation(async (method: string) => {
if (method === "Runtime.evaluate") {
restorationStarted();
await held;
}
return policyResponse(method);
});
vi.useFakeTimers();
const { cleanup, page, session } = await prepareLateStopRetry();
try {
const firstClose = session.close(false).then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
await started;
await vi.advanceTimersByTimeAsync(5_001);
await expect(firstClose).resolves.toMatchObject({ ok: false });
finishRestoration();
await transportClosed;
await vi.advanceTimersByTimeAsync(0);
cancellation.abort();
const { promise: heldStop, resolve: finishStop } = deferred();
const { promise: startedStop, resolve: stopStarted } = deferred();
page.evaluate.mockImplementation(async (script: unknown) => {
@@ -63,3 +63,57 @@ it("settles caller cancellation and removes its local timer", async () => {
vi.useRealTimers();
}
});
it("keeps a root document transition distinct from a child frame event", async () => {
const root = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const rootResult = root.start(30_000);
root.ingest({
method: "Page.frameNavigated",
params: { frame: { id: "new-main", url: "https://example.test/" } },
});
await expect(rootResult).resolves.toBe("document_changed");
const child = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const childResult = child.start(30_000);
child.ingest({
method: "Page.frameNavigated",
params: {
frame: {
id: "child-frame",
parentId: "selected-main",
url: "https://example.test/child",
},
},
});
expect(child.active).toBe(true);
child.end("window_elapsed");
await expect(childResult).resolves.toBe("window_elapsed");
});
it("surfaces malformed frame identity as a protocol failure without guessing a transition", async () => {
const window = new CdpExecutionWindow({
target: { frame_id: "selected-main" },
operation: "observe_web_execution",
options: {},
});
const result = window.start(30_000);
window.ingest({
method: "Page.frameNavigated",
params: {
frame: { id: "child-frame", parentId: 7, url: "https://example.test/" },
},
});
await expect(result).rejects.toMatchObject({
_tag: "BrowserObservationError",
reason: "protocol_error",
});
expect(window.reason).toBeUndefined();
});
+25 -8
View File
@@ -3,6 +3,8 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import type { WebExecution } from "../../domain/webExecution.js";
import type { CdpEvent } from "../CdpConnection.js";
import type { CdpRuntimeSession } from "./CdpRuntimeSession.js";
import { BrowserObservationError } from "../../domain/browserObservationError.js";
import { cdpFrameIdentity } from "../CdpCaptureEventHelpers.js";
type EndReason = WebExecution["window"]["end_reason"];
/** One finite document-scoped window, with an actual armed point and event-driven termination. */
@@ -33,15 +35,30 @@ export class CdpExecutionWindow {
if (!this.active) return;
if (event.method === "Runtime.executionContextsCleared")
this.end("document_changed");
if (
event.method === "Page.frameNavigated" &&
typeof event.params === "object" &&
event.params !== null &&
"frame" in event.params
) {
const frame = event.params.frame;
if (typeof frame === "object" && frame !== null && !("parentId" in frame))
if (event.method === "Page.frameNavigated") {
const identity = cdpFrameIdentity(event, this.session.target.frame_id);
if (identity.identityIssue !== null || identity.parentIssue !== null) {
this.fail(
new BrowserObservationError(this.session.operation, "protocol_error"),
);
return;
}
if (identity.selection === "main") {
this.end("document_changed");
return;
}
if (identity.parentRelation === "child") return;
if (
identity.selection === "other" &&
identity.parentRelation === "root"
) {
this.end("document_changed");
return;
}
this.fail(
new BrowserObservationError(this.session.operation, "protocol_error"),
);
return;
}
if (
event.method === "Page.frameDetached" &&
+27 -18
View File
@@ -57,6 +57,14 @@ import {
OwnedCommandFailure,
} from "../process/OwnedCommand.js";
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
TSHARK_COMMAND_ENV,
assertWiresharkExecutableIdentity,
wiresharkExecutableIdentity,
locateWiresharkTool,
parseWiresharkVersion,
selectWiresharkTool,
} from "../pcap/PcapConfiguration.js";
const OPERATION = "inspect_packet_capture";
export const TSHARK_PROVIDER_IDENTITY = {
@@ -139,18 +147,22 @@ export class TsharkCaptureProvider implements PacketCapturePort {
await this.#resources.release();
if (this.platform !== "linux")
throw new Error(
"Packet inspection requires Linux with util-linux prlimit and REA_TSHARK_COMMAND so expanded captures are processed under an enforced address-space limit.",
"Packet inspection requires Linux with util-linux prlimit and TShark 4.x (on PATH or through absolute REA_TSHARK_COMMAND) so expanded captures are processed under an enforced address-space limit.",
);
const selection = selectWiresharkTool(this.environment, "tshark");
const configured = locateWiresharkTool(selection, this.environment);
if (configured === undefined)
throw new Error(
`TShark 4.x was not found on PATH; add it to PATH or set absolute ${TSHARK_COMMAND_ENV}.`,
);
const configured = this.environment.REA_TSHARK_COMMAND ?? "";
if (!isAbsolute(configured))
throw new Error("Supply absolute REA_TSHARK_COMMAND for TShark 4.x.");
const command = await realpath(configured);
await access(command, constants.X_OK);
const tool = await this.#resources.read(
const toolSnapshot = await this.#resources.read(
command,
64 * 1024 * 1024,
options?.signal,
);
const tool = wiresharkExecutableIdentity(toolSnapshot);
const configuredLimiter =
this.environment.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
if (!isAbsolute(configuredLimiter))
@@ -220,13 +232,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
"Configured resource limiter changed during analysis.",
{ capturedOutput },
);
if (current.sha256 !== tool.sha256)
throw new AnalysisArtifactChangedError(
operation,
command,
"Configured TShark executable changed during analysis.",
{ capturedOutput },
);
assertWiresharkExecutableIdentity(
operation,
command,
tool,
wiresharkExecutableIdentity(current),
{ capturedOutput },
);
phase = previousPhase;
resourcePath = "path" in input ? input.path : command;
if (output.exitCode !== 0)
@@ -245,11 +257,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
};
phase = "configuration";
const version = await execute(["--version"]);
if (
!/^TShark \(Wireshark\) 4\.[0-9]+\.[0-9]+(?:[ .(\r\n]|$)/u.test(
version.stdout.text,
)
)
if (parseWiresharkVersion(version.stdout.text, "tshark") === null)
throw new Error(
"Expected TShark 4.x; inspect the selected installation.",
);
@@ -257,6 +265,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
diagnostics,
tool: {
configured_path: configured,
selection_source: selection.source,
path: command,
sha256: tool.sha256,
version_banner: version.stdout.text,
@@ -553,7 +562,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
...(observedOutput === undefined
? {}
: { capturedOutput: observedOutput }),
userMessage: `${reason} On Linux, configure TShark 4.x through REA_TSHARK_COMMAND and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
userMessage: `${reason} On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit through REA_TSHARK_PRLIMIT_COMMAND (default /usr/bin/prlimit).`,
},
);
if (
+69
View File
@@ -0,0 +1,69 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { build } from "plist";
import { afterEach, expect, it } from "vitest";
import { createCli } from "./cli.js";
import { parseEvidence } from "./domain/evidence.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((path) => rm(path, { recursive: true, force: true })),
);
});
it("inspects a keyless app bundle through the public CLI and preserves resource provenance", async () => {
const parent = await mkdtemp(join(tmpdir(), "rea-bundle-resource-cli-"));
temporaryDirectories.push(parent);
const bundle = join(parent, "Fixture.app");
const resources = join(bundle, "Resources");
await mkdir(resources, { recursive: true });
await writeFile(join(bundle, "Info.plist"), "<plist><dict/></plist>");
await writeFile(
join(resources, "Archive.plist"),
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "resource-only-value"],
$top: { root: { UID: 1 } },
}),
);
const cli = createCli({ REA_LOG_LEVEL: "silent" });
let stdout = "";
let exitCode = 0;
await cli.serve(
["inspect-keyed-archive", bundle, "Resources/Archive.plist", "--json"],
{
env: {},
exit: (code) => {
exitCode = code;
},
stdout: (text) => {
stdout += text;
},
},
);
expect(exitCode, stdout).toBe(0);
expect(parseEvidence(JSON.parse(stdout))).toMatchObject({
subject: null,
normalized_result: {
archive_path: "Resources/Archive.plist",
target_sha256: null,
resource_root: {
caller_path: bundle,
canonical_path: bundle,
executable_sha256: null,
},
objects: expect.arrayContaining([
expect.objectContaining({ value: "resource-only-value" }),
]),
},
});
});
+2
View File
@@ -193,6 +193,8 @@ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({
/** MCP operations without a dedicated CLI command or direct command alias. */
export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
// MCP session control selects a resource root; CLI resource commands take paths directly.
"open_bundle_resources",
"address_name",
"comment",
"current_address",
+35 -11
View File
@@ -1,7 +1,11 @@
import { absoluteCallerPath } from "./filesystem/callerPath.js";
import { sealOwnedJsonInput } from "./application/OwnedJsonInput.js";
import { readJsonInputFile } from "./application/JsonInputFile.js";
import {
JsonInputCancellationFailure,
JsonInputPhysicalReadFailure,
readJsonInputFile,
} from "./application/JsonInputFile.js";
import { NonRegularFileReadError } from "./filesystem/RegularFile.js";
import {
AnalysisAccessDeniedError,
@@ -22,14 +26,18 @@ export const parseCliJsonInput = async (
| { readonly ok: true; readonly value: unknown }
| { readonly ok: false; readonly error: JsonValue }
> => {
const cancelled = () => ({
const cancelled = (cause: unknown) => ({
ok: false as const,
error: {
error: "Application workflow failed",
...projectAnalysisError(new AnalysisCancelledError(operation)),
...projectAnalysisError(
new AnalysisCancelledError(operation, {
cause,
}),
),
},
});
if (signal?.aborted) return cancelled();
if (signal?.aborted) return cancelled(signal.reason);
try {
const inline = safeParseJson(value);
if (inline.ok) {
@@ -52,7 +60,7 @@ export const parseCliJsonInput = async (
undefined,
options?.snapshot ?? false,
);
if (parsed.ok && signal?.aborted) return cancelled();
if (parsed.ok && signal?.aborted) return cancelled(signal.reason);
return parsed.ok
? { ok: true, value: parsed.value }
: {
@@ -68,12 +76,17 @@ export const parseCliJsonInput = async (
},
};
} catch (cause: unknown) {
if (
signal?.aborted &&
(cause === signal.reason ||
(cause instanceof Error && cause.name === "AbortError"))
)
return cancelled();
if (cause instanceof JsonInputCancellationFailure)
return cancelled(cause.reason);
if (cause instanceof JsonInputPhysicalReadFailure) {
const physicalCause = cause.physicalCause;
const systemCode = accessDeniedSystemCode(physicalCause);
return systemCode === undefined
? jsonFileError(value, operation, "read-failed", physicalCause)
: jsonAccessDeniedError(value, operation, systemCode, physicalCause);
}
if (signal?.aborted && isJsonInputCancellation(cause, signal))
return cancelled(signal.reason);
const systemCode = accessDeniedSystemCode(cause);
if (systemCode !== undefined)
return jsonAccessDeniedError(value, operation, systemCode, cause);
@@ -93,6 +106,17 @@ export const parseCliJsonInput = async (
}
};
const isJsonInputCancellation = (
cause: unknown,
signal: AbortSignal,
): boolean =>
cause === signal.reason ||
(cause instanceof Error &&
cause.name === "AbortError" &&
"code" in cause &&
cause.code === "ABORT_ERR" &&
cause.cause === signal.reason);
const accessDeniedSystemCode = (
cause: unknown,
): "EACCES" | "EPERM" | undefined => {
+66 -7
View File
@@ -12,6 +12,11 @@ import { AnalysisProviderRegistry } from "../application/binary/AnalysisProvider
import { composeBinarySession } from "../application/binary/BinarySessionComposition.js";
import { LazyAnalysisProvider } from "../application/binary/LazyAnalysisProvider.js";
import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js";
import {
BUNDLE_ARTIFACT_RESOURCE_OPERATIONS,
BUNDLE_NATIVE_RESOURCE_OPERATIONS,
combineBundleResourceClients,
} from "../application/binary/BundleResourceSession.js";
/**
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
@@ -67,9 +72,49 @@ const composeDeepBinarySession = (
auxiliaryDeclarations.map(
(declaration) => new LazyAnalysisProvider(declaration),
),
createBundleResourceFactory(environment, process.platform),
);
};
const createBundleResourceFactory = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
) => {
return {
createClient: async (
root: import("../application/AppBundleExecutable.js").ResolvedAppBundleResourceRoot,
) => {
const [{ ArtifactProvider }, { NativeMacOSProvider }] = await Promise.all(
[
import("../artifacts/ArtifactProvider.js"),
import("../native/NativeMacOSProvider.js"),
],
);
const artifact = new ArtifactProvider(environment, platform);
const native = new NativeMacOSProvider(environment, undefined, platform);
const capabilities = [
...artifact
.capabilities()
.filter(({ operation }) =>
BUNDLE_ARTIFACT_RESOURCE_OPERATIONS.has(operation),
),
...native
.capabilities()
.filter(({ operation }) =>
BUNDLE_NATIVE_RESOURCE_OPERATIONS.has(operation),
),
];
return {
client: combineBundleResourceClients(
artifact.createBundleResourceClient(root),
native.createBundleResourceClient(root),
),
capabilities,
};
},
};
};
/** Compose an execution-free managed session without native provider selection. */
export const createManagedBinarySession = (): BinarySession =>
composeBinarySession(new AnalysisProviderRegistry([]), [
@@ -82,12 +127,19 @@ export const createArtifactBinarySession = (
platform: NodeJS.Platform = process.platform,
): Promise<BinarySession> =>
import("../artifacts/ArtifactProvider.js").then(({ ArtifactProvider }) =>
composeBinarySession(new AnalysisProviderRegistry([]), [
new ArtifactProvider(
composeBinarySession(
new AnalysisProviderRegistry([]),
[
new ArtifactProvider(
snapshotEnvironment(selectedEnvironment, platform),
platform,
),
],
createBundleResourceFactory(
snapshotEnvironment(selectedEnvironment, platform),
platform,
),
]),
),
);
/** Compose native inspection utilities without candidates for deep analysis. */
@@ -96,11 +148,18 @@ export const createNativeBinarySession = (
platform: NodeJS.Platform = process.platform,
): Promise<BinarySession> =>
import("../native/NativeMacOSProvider.js").then(({ NativeMacOSProvider }) =>
composeBinarySession(new AnalysisProviderRegistry([]), [
new NativeMacOSProvider(
composeBinarySession(
new AnalysisProviderRegistry([]),
[
new NativeMacOSProvider(
snapshotEnvironment(selectedEnvironment, platform),
undefined,
platform,
),
],
createBundleResourceFactory(
snapshotEnvironment(selectedEnvironment, platform),
undefined,
platform,
),
]),
),
);
@@ -15,7 +15,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_packet_fields"),
kind: "artifact-provider",
description:
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, REA_TSHARK_COMMAND and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
"Discover protocols or field names/types registered in the installed TShark 4.x. kind defaults to protocols; use fields and optional exact protocol abbreviation to select field definitions. query searches name/abbreviation/description by case-insensitive literal substring. Return 50 matches by default with offset/limit paging; null requests all remaining within resource limits. Preserve type/base/bitmask/description, duplicate registrations, source line numbers, coverage, tool identity and diagnostics. Linux, TShark 4.x (PATH or absolute REA_TSHARK_COMMAND) and prlimit required. No capture file, interface or binary target needed. Registrations describe tool capabilities, not observed traffic. Each call rereads the glossary; use selected fields with inspect_packet_capture.",
inputSchema: inspectPacketFieldsInputSchema,
outputSchema: evidenceResultOf(packetFieldsCatalogSchema),
examples: [
@@ -31,7 +31,7 @@ export const CAPTURE_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_packet_capture"),
kind: "artifact-provider",
description:
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
"Inspect caller-selected fields in an existing local packet capture with TShark 4.x. Return 50 matching frames by default, with match_offset/match_limit paging and coverage counts; match_limit:null requests all remaining matches. Preserve repeated values, frame numbers, display filter, decode-as rules and pass mode in inline Evidence with source/tool identity and diagnostics. Optional packet_limit counts input packets before filtering; output paging does not shorten dissection or two-pass reassembly. Requires Linux, util-linux prlimit, and TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND. File-only analysis; no live capture or device communication. Missing fields or no matches do not establish absence of device traffic.",
inputSchema: inspectPacketCaptureInputSchema,
outputSchema: evidenceResultOf(packetCaptureSchema),
examples: [
+20 -1
View File
@@ -6,6 +6,7 @@ const traceLiteralInputSchema = z.strictObject({
});
export const DEFAULT_CALL_RELATIONSHIP_DIRECTION = "forward";
export const MAX_ENHANCED_RECORDS = 20_000;
const callRelationshipDirectionSchema = z
.enum(["forward", "backward"])
.default(DEFAULT_CALL_RELATIONSHIP_DIRECTION);
@@ -17,7 +18,25 @@ export type CallRelationshipDirection = z.output<
export const enhancedInputSchemas = {
trace_native_values: nativeValueTraceInputSchema,
inspect_native_dispatch_metadata: z.strictObject({
max_records: z.number().int().min(1).max(20_000).default(5_000),
max_records: z
.number()
.int()
.min(1)
.max(MAX_ENHANCED_RECORDS)
.default(5_000),
objc_class_import_addresses: z
.array(z.string())
.max(MAX_ENHANCED_RECORDS)
.describe(
"Optional exact pointer-slot addresses from the selected name inventory; used to report decoded Objective-C class import bindings.",
)
.optional(),
objc_class_import_addresses_complete: z
.boolean()
.describe(
"Whether the candidate addresses include every selected class-label row; false keeps binding coverage partial when the existing record budget truncates the scan.",
)
.optional(),
}),
get_objc_classes: z.strictObject({ pattern: z.string().default("") }),
get_objc_protocols: z.strictObject({}),
+2 -2
View File
@@ -27,12 +27,12 @@ const enhanced = <Name extends string, Schema extends z.ZodObject>(
export const ENHANCED_TOOL_CONTRACTS = [
enhanced(
"inspect_native_dispatch_metadata",
"Inspect the bound provider's name inventory for Objective-C class/method symbols and Swift mangled symbols, returning at most max_records decoded names. Includes target SHA-256 when available, provider identity/version, analysis-profile digest, facet coverage, and per-record Evidence. This is symbol-level evidence, not runtime metadata: ivar layouts, protocol conformances, witness/class tables, and relative pointers are explicitly marked unsupported unless a provider supplies those decoders. The provider controls how its name inventory is acquired.",
"Inspect the bound provider's name inventory for Objective-C class/method symbols and Swift mangled symbols, returning at most max_records decoded names. Optional objc_class_import_addresses request exact pointer-slot binding facts for those addresses; they are decoded only from the selected Mach-O slice and retain partial or unsupported coverage when not established. Includes target SHA-256 when available, provider identity/version, analysis-profile digest, facet coverage, and per-record Evidence. This is static symbol and metadata evidence, not runtime metadata: ivar layouts, protocol conformances, witness/class tables, and relative pointers are explicitly marked unsupported unless a provider supplies those decoders. The provider controls how its name inventory is acquired.",
enhancedInputSchemas.inspect_native_dispatch_metadata,
),
enhanced(
"get_objc_classes",
"Discover and deduplicate Objective-C class labels, optionally filtering by literal substring.",
"Discover Objective-C class labels, optionally filtering by literal substring. Rows are coalesced only when exact selected-image pointer-fixup evidence proves that their own addresses bind the same imported class symbol, binding stream, raw library ordinal and resolved library, weak status, and addend. Rows without that evidence remain separate, with identity coverage reported as unknown, partial, or unsupported.",
enhancedInputSchemas.get_objc_classes,
),
enhanced(
+5
View File
@@ -65,6 +65,11 @@ export const openBinaryInputSchema = z
});
});
/** Open only app resources when no declared executable is available. */
export const openBundleResourcesInputSchema = z.strictObject({
path: binaryTargetPathSchema,
});
/** Input contract for closing a target after an optional atomic snapshot. */
export const closeBinaryInputSchema = z.strictObject({
snapshot_path: snapshotPathSchema.optional(),
+8 -2
View File
@@ -17,6 +17,7 @@ import { sessionOutputSchemas } from "./toolOutputSchemaGroups.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import {
closeBinaryInputSchema,
openBundleResourcesInputSchema,
openBinaryInputSchema,
} from "./sessionLifecycleInputs.js";
import { binarySessionInputSchema } from "./sessionStatusContract.js";
@@ -67,14 +68,19 @@ export const SESSION_TOOL_CONTRACTS = [
"Open a local executable, application bundle, archive, JavaScript, source map, plist, or analysis database after validation. format=dos-com explicitly interprets 1..65280 headerless bytes as a DOS COM analysis image. format=raw-binary interprets headerless bytes with a required architecture and entry_point; file offset 0 maps at that address. Omission preserves header-based detection. provider_id selects one deep provider or deterministic auto selection; the binding remains stable until close or an explicit switch, with no failure fallback. Reopening the same target and profile retains its active database and metadata edits. An optional analysis snapshot is imported atomically and must match the binary identity, concrete provider, and canonical analysis profile exactly; importing into a session with metadata mutations requires closing and reopening it first. Call close_binary when the investigation is finished.",
openBinaryInputSchema,
),
session(
"open_bundle_resources",
"Open a local app bundle for read-only plist, Interface Builder, asset catalog, and keyed archive inspection without resolving or inventing its executable identity. This switches the shared session to a resource-only lane; executable analysis is unavailable until another target is opened.",
openBundleResourcesInputSchema,
),
session(
"close_binary",
"When the investigation is finished, drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active target and every provider resource started for it. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.",
"When the investigation is finished, drain earlier provider requests, optionally write a provider-neutral analysis snapshot atomically to the caller-supplied path, then close the active analysis session and every provider resource started for it. Resource-only app sessions have no binary snapshot binding, so close them without snapshot_path. Later provider requests wait until this lifecycle operation finishes. Existing files require explicit overwrite; a failed save leaves the session open so cached analysis is not lost. Metadata edits prevent immutable snapshot saves until the session is recreated; export_evidence_bundle preserves mutable observations before closing without a snapshot.",
closeBinaryInputSchema,
),
session(
"binary_session",
"Report the complete current target, provider, capability availability, client-feature, analysis, and server-identity status without starting analysis.",
"Report the active binary target or resource-only app root, provider and capability availability when applicable, client-feature, analysis, and server-identity status without starting analysis.",
binarySessionInputSchema,
),
session(
+1
View File
@@ -53,6 +53,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
trace_call_path: { start: "0x1000", goal: "0x1100" },
trace_native_ui_action: { action: "buildTapped:" },
open_binary: { path: "/tmp/fixture" },
open_bundle_resources: { path: "/tmp/Fixture.app" },
export_evidence_bundle: { path: "/tmp/evidence.json" },
get_evidence_bundle: {
detail: "summary",
+1
View File
@@ -679,6 +679,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
project_apple_application_graph: evidence,
project_harmony_application_graph: evidence,
open_binary: effects({ mutatesSession: true, launchesProcess: true }),
open_bundle_resources: effects({ mutatesSession: true }),
close_binary: effects({
mutatesSession: true,
writesFilesystem: true,
+9 -1
View File
@@ -26,6 +26,7 @@ import { nativeFunctionAnnotationsSchema } from "../domain/native/nativeFunction
import { nativeLoadImageSchema } from "../domain/native/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/native/nativeUiObservation.js";
import { nativeValueTraceSchema } from "../domain/native/nativeValueTrace.js";
import { bundleResourceRootSchema } from "../domain/apple/bundleResourceRoot.js";
import { nativeDataTypeSchema } from "../domain/native/nativeDataType.js";
import { nativeCallObservationResultSchema } from "../domain/native/nativeCallObservation.js";
import {
@@ -87,6 +88,7 @@ import {
lifecycleResultOf,
nullableText,
procedureInfoOutput,
objcClassDiscoveryOutput,
evidenceResultOf,
segmentOutput,
sessionProvider,
@@ -308,7 +310,7 @@ export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
),
trace_native_values: evidenceResultOf(nativeValueTraceSchema),
trace_native_ui_action: evidenceResultOf(nativeInvestigationTraceSchema),
get_objc_classes: symbolDiscoveryOutput("classes"),
get_objc_classes: objcClassDiscoveryOutput,
get_objc_protocols: symbolDiscoveryOutput("protocols"),
batch_decompile: evidenceResultOf(
z.object({
@@ -511,6 +513,7 @@ export const sessionOutputSchemas = {
rawLifecycleTargetSchema,
]),
),
open_bundle_resources: lifecycleResultOf(bundleResourceRootSchema),
close_binary: lifecycleResultOf(
z.union([
z.null(),
@@ -540,6 +543,11 @@ export const sessionOutputSchemas = {
binary_session: lifecycleResultOf(
z.union([
sessionProvider.extend({ open: z.literal(false) }),
sessionProvider.extend({
open: z.literal(true),
session_kind: z.literal("bundle_resources"),
resource_root: bundleResourceRootSchema,
}),
sessionProvider.extend({
open: z.literal(true),
...detectedLifecycleTargetSchema.shape,
@@ -23,6 +23,7 @@ import {
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { relationshipCoverageSchema } from "../domain/relationshipCoverage.js";
import { ZIP_PACKAGE_FORMATS } from "../domain/zipPackageFormat.js";
import { objcClassImportIdentitySchema } from "../domain/native/objcSwiftMetadata.js";
/** Specialize the single normalized result inside a complete Evidence record. */
export const evidenceResultOf = <Schema extends z.ZodType>(schema: Schema) =>
@@ -606,6 +607,32 @@ export const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
}),
);
export const objcClassDiscoveryOutput = evidenceResultOf(
z.object({
count: z.number().int().min(0),
classes: z.array(
addressedEntry.extend({
binding_identity: objcClassImportIdentitySchema
.extend({
slot_addresses: z.array(z.string().min(1)).min(1),
})
.optional(),
}),
),
identity_coverage: z.strictObject({
status: z.enum([
"complete",
"partial",
"unsupported",
"not_requested",
"unknown",
"not_applicable",
]),
reason: z.string().nullable(),
}),
}),
);
export const graphNode = z.discriminatedUnion("status", [
z.object({
address: z.string(),
+6 -1
View File
@@ -78,6 +78,7 @@ import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js";
import { err, ok, type Result } from "../result.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { safeParseOwnedBinaryLayout } from "../native/binaryLayout.js";
import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js";
import { functionDossierSchema } from "../hopperValues.js";
import {
artifactGraphManifestSchema,
@@ -346,7 +347,9 @@ const coverageSchema = z.strictObject({
const artifactSchema = z.strictObject({
path: z
.string()
.describe("Exact recorded artifact path, including an empty string."),
.describe(
"Selected artifact path, or the canonical resource root when resource-only Evidence has no executable subject.",
),
sha256: digestSchema.nullable(),
});
@@ -524,6 +527,7 @@ export const machoSummaryViewResultSchema = z.strictObject({
export const interfaceBuilderSummaryViewResultSchema = z.strictObject({
...summaryViewResultBase,
parent_operation: z.literal("decode_interface_builder"),
resource_root: bundleResourceRootSchema.optional(),
summary: interfaceBuilderSummarySchema,
});
@@ -782,6 +786,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
view: interfaceBuilderViewRequestSchema,
items: interfaceBuilderViewItemsSchema,
...viewResultBase,
resource_root: bundleResourceRootSchema.optional(),
...interfaceBuilderViewContextSchema.shape,
parent_operation: z.literal("decode_interface_builder"),
}),
@@ -1,11 +1,19 @@
import { describe, expect, it } from "vitest";
import {
analysisViewResultSchema,
projectAnalysisView,
type AnalysisViewParent,
} from "./analysisView.js";
const target = "a".repeat(64);
const resourceRoot = {
caller_path: "/Applications/Keyless.app",
canonical_path: "/Applications/Keyless.app",
info_plist_path: "/Applications/Keyless.app/Info.plist",
layout: "root" as const,
executable_sha256: null,
};
const evidence = (artifact_path: string) => [
{
kind: "interface_builder_resource" as const,
@@ -68,6 +76,15 @@ const report = (truncated = false) => ({
},
limitations: ["Recognized archive fields only."],
});
const resourceReport = () => {
const decoded = report();
return {
...decoded,
target_sha256: null,
resource_root: resourceRoot,
graph: { ...decoded.graph, target_sha256: null },
};
};
const parent = (
normalizedResult: unknown = report(),
sha256 = target,
@@ -86,6 +103,33 @@ const parent = (
});
describe("Interface Builder analysis views", () => {
it("keeps resource-root provenance in both summary and detail views without a subject", () => {
const source: AnalysisViewParent = {
...parent(resourceReport()),
subject: null,
};
const summary = projectAnalysisView(source, { kind: "summary" });
const detail = projectAnalysisView(source, {
kind: "interface-builder",
collection: "documents",
offset: 0,
limit: 64,
});
if (!summary.ok) throw summary.error;
if (!detail.ok) throw detail.error;
expect(analysisViewResultSchema.parse(summary.value)).toMatchObject({
artifact: { path: resourceRoot.canonical_path, sha256: null },
resource_root: resourceRoot,
summary: { target_sha256: null },
});
expect(analysisViewResultSchema.parse(detail.value)).toMatchObject({
artifact: { path: resourceRoot.canonical_path, sha256: null },
resource_root: resourceRoot,
items: report().documents,
});
});
it("summarizes documents and graph counts without rows", () => {
const projected = projectAnalysisView(parent(), { kind: "summary" });
if (!projected.ok) throw projected.error;
@@ -6,10 +6,12 @@ import {
interfaceBuilderAnalysisSchema,
interfaceBuilderDocumentSchema,
} from "../apple/interfaceBuilderGraph.js";
import { bundleResourceRootSchema } from "../apple/bundleResourceRoot.js";
import {
nativeInvestigationCoverageSchema,
nativeInvestigationEdgeSchema,
nativeInvestigationNodeSchema,
nativeInvestigationProviderSchema,
} from "../native/nativeInvestigationGraph.js";
import { err, ok, type Result } from "../result.js";
import { parseAnalysisViewResult } from "./analysisViewParent.js";
@@ -67,9 +69,12 @@ const countsSchema = z.record(z.string(), z.number().int().positive());
/** Every decoded document with graph counts; rows and evidence stay retained. */
export const interfaceBuilderSummarySchema = z.strictObject({
target_sha256: interfaceBuilderAnalysisSchema.shape.target_sha256,
provider: interfaceBuilderAnalysisSchema.shape.graph.shape.provider,
documents: interfaceBuilderAnalysisSchema.shape.documents,
target_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
provider: nativeInvestigationProviderSchema,
documents: interfaceBuilderDocumentSchema.array().max(64),
available_counts: z.strictObject({
nodes: z.number().int().nonnegative(),
edges: z.number().int().nonnegative(),
@@ -132,7 +137,7 @@ const summarizeInterfaceBuilder = (
graph.edges.length +
graph.coverage.length;
return {
summary: {
summary: interfaceBuilderSummarySchema.parse({
target_sha256: report.target_sha256,
provider: graph.provider,
documents: report.documents,
@@ -146,7 +151,7 @@ const summarizeInterfaceBuilder = (
coverage_statuses: tally(graph.coverage.map((row) => row.status)),
truncated: graph.truncated,
limitation_count: limitationCount,
},
}),
coverage: completeWithinViewCoverage(total, total),
};
};
@@ -180,9 +185,15 @@ export const projectInterfaceBuilderView = (
parent_operation: "decode_interface_builder" as const,
parent_digest: parent.evidenceId.slice(3),
artifact: {
path: parent.subject?.local_path ?? "",
path:
parent.subject?.local_path ??
report.resource_root?.canonical_path ??
"",
sha256: report.target_sha256,
},
...(report.resource_root === undefined
? {}
: { resource_root: report.resource_root }),
limitations,
unknowns: [
"Rows describe recognized archive objects and connections only; unrecognized classes, fields and runtime-created UI remain unknown.",
@@ -74,6 +74,23 @@ const diagnostics = (offset: number, limit: number) =>
limit,
}) as const;
it("reads historical capture Evidence without inventing executable identity", () => {
const result = projectAnalysisView(capture(""), { kind: "summary" });
if (!result.ok) throw result.error;
if (result.value.kind !== "summary")
throw new Error("Expected a packet summary.");
if (!("client" in result.value.summary))
throw new Error("Expected packet client facts.");
expect(result.value.summary.client).toMatchObject({
tshark_resolved_path: null,
tshark_sha256: null,
tshark_identity_status: "unresolved",
capinfos_resolved_path: null,
capinfos_sha256: null,
capinfos_identity_status: "unresolved",
});
});
it("pages diagnostics without splitting a surrogate pair", () => {
const parent = capture("ab😀cd");
expect(projectAnalysisView(parent, diagnostics(3, 2))).toMatchObject({
+2 -2
View File
@@ -1,6 +1,6 @@
import { analysisViewInputError } from "./analysisViewErrors.js";
import { z } from "zod";
import { pcapResultSchemas } from "../pcap/pcapAnalysis.js";
import { pcapEvidenceSchemas } from "../pcap/pcapAnalysis.js";
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
import type { AnalysisError } from "../analysisErrorBase.js";
import { err, ok, type Result } from "../result.js";
@@ -15,7 +15,7 @@ import {
} from "./analysisViewCoverage.js";
import { analysisTextPage } from "./analysisViewText.js";
const captureSchema = pcapResultSchemas.inspect_pcap_capture;
const captureSchema = pcapEvidenceSchemas.inspect_pcap_capture;
const fields = captureSchema.shape;
const pageShape = {
kind: z.literal("pcap"),
@@ -1,11 +1,39 @@
import { describe, expect, it } from "vitest";
import {
appleAssetCatalogResultSchema,
appleAssetCatalogInputSchema,
parseAppleAssetCatalogRecords,
projectAppleAssetCatalogPage,
} from "./appleAssetCatalog.js";
it("requires resource-root provenance when asset results have no executable identity", () => {
const base = {
target_sha256: null,
catalogs: [],
total_records: 0,
offset: 0,
limit: 1,
records: [],
resource_key_matches: [],
next_offset: null,
truncated: false,
limitations: [],
};
const resource_root = {
caller_path: "/tmp/Fixture.app",
canonical_path: "/tmp/Fixture.app",
info_plist_path: "/tmp/Fixture.app/Info.plist",
layout: "root" as const,
executable_sha256: null,
};
expect(
appleAssetCatalogResultSchema.parse({ ...base, resource_root })
.resource_root,
).toEqual(resource_root);
expect(appleAssetCatalogResultSchema.safeParse(base).success).toBe(false);
});
describe("compiled Apple asset catalog projection", () => {
it("preserves metadata and paginates across catalog boundaries", () => {
const catalogs = [
+20 -13
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { bundleResourceBoundResultSchema } from "./bundleResourceRoot.js";
import { jsonValueSchema } from "../jsonValue.js";
import { AnalysisOutputError } from "../analysisErrorCore.js";
@@ -66,18 +67,20 @@ const resourceKeyMatchSchema = z.strictObject({
});
/** Normalized result returned by asset catalog inspection. */
export const appleAssetCatalogResultSchema = z.strictObject({
target_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
catalogs: z.array(catalogSchema),
total_records: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
limit: z.number().int().positive(),
records: z.array(recordSchema),
resource_key_matches: z.array(resourceKeyMatchSchema),
next_offset: z.number().int().nonnegative().nullable(),
truncated: z.boolean(),
limitations: z.array(z.string()),
});
export const appleAssetCatalogResultSchema = bundleResourceBoundResultSchema(
z.string().regex(/^[a-f0-9]{64}$/u),
{
catalogs: z.array(catalogSchema),
total_records: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
limit: z.number().int().positive(),
records: z.array(recordSchema),
resource_key_matches: z.array(resourceKeyMatchSchema),
next_offset: z.number().int().nonnegative().nullable(),
truncated: z.boolean(),
limitations: z.array(z.string()),
},
);
export type AppleAssetCatalogRecord = z.infer<typeof recordSchema>;
@@ -106,7 +109,8 @@ export const parseAppleAssetCatalogRecords = (
/** Project catalog records and apply stable global pagination. */
export const projectAppleAssetCatalogPage = (input: {
readonly targetSha256: string;
readonly targetSha256: string | null;
readonly resourceRoot?: import("./bundleResourceRoot.js").BundleResourceRoot;
readonly catalogs: readonly {
readonly path: string;
readonly sha256: string;
@@ -175,6 +179,9 @@ export const projectAppleAssetCatalogPage = (input: {
});
return appleAssetCatalogResultSchema.parse({
target_sha256: input.targetSha256,
...(input.resourceRoot === undefined
? {}
: { resource_root: input.resourceRoot }),
catalogs: input.catalogs.map(
({ path, sha256, records: catalogRecords }) => ({
path,
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
/** Provenance for app resources, independent of executable file identity. */
export const bundleResourceRootSchema = z.strictObject({
caller_path: z.string().min(1),
canonical_path: z.string().min(1),
info_plist_path: z.string().min(1),
layout: z.enum(["contents", "root"]),
executable_sha256: z
.string()
.regex(/^[a-f0-9]{64}$/u)
.nullable(),
});
export type BundleResourceRoot = z.infer<typeof bundleResourceRootSchema>;
/** Output identity: executable-bound, or explicitly rooted resource-only. */
export const bundleResourceBoundResultSchema = <Fields extends z.ZodRawShape>(
executableSha256: z.ZodType<string>,
fields: Fields,
) =>
z.union([
z.strictObject({
target_sha256: executableSha256,
resource_root: bundleResourceRootSchema.optional(),
...fields,
}),
z.strictObject({
target_sha256: z.null(),
resource_root: bundleResourceRootSchema,
...fields,
}),
]);
+14 -2
View File
@@ -68,7 +68,13 @@ export const joinPath = (directory: string, rest: string): string =>
directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
export type Expansion =
| { readonly scope: "target"; readonly path: string }
| {
readonly scope: "target";
/** Source-facing expansion retained in candidate Evidence. */
readonly path: string;
/** Root-relative lookup identity when `path` is an absolute source path. */
readonly lookupPath?: string;
}
| { readonly scope: "outside"; readonly path: string }
| { readonly scope: "undetermined"; readonly path: string };
@@ -78,6 +84,7 @@ export const expandPrefix = (
context: {
readonly ownerDirectory: string;
readonly executable: string | null;
readonly rootRelativePath: (absolutePath: string) => string | undefined;
},
): Expansion => {
const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw);
@@ -92,7 +99,12 @@ export const expandPrefix = (
path: joinPath(directoryOf(context.executable), rest),
};
}
if (raw.startsWith("/")) return { scope: "outside", path: raw };
if (raw.startsWith("/")) {
const lookupPath = context.rootRelativePath(raw);
return lookupPath === undefined
? { scope: "outside", path: raw }
: { scope: "target", path: raw, lookupPath };
}
return { scope: "undetermined", path: raw };
};
+1
View File
@@ -80,6 +80,7 @@ const memoryView = (
entries.set(path, { kind: "symlink", target });
}
return {
rootRelativePath: () => undefined,
entry: (path) => Promise.resolve(entries.get(path)),
image: (path) => {
const facts = files[path];
+32 -11
View File
@@ -114,6 +114,8 @@ export type DylibTreeEntry =
/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
export interface DylibTreeView {
/** Map an absolute source path only when it is spelled beneath this root. */
rootRelativePath(absolutePath: string): string | undefined;
entry(path: string): Promise<DylibTreeEntry | undefined>;
image(path: string): Promise<MachoImageFacts>;
}
@@ -252,6 +254,24 @@ interface CandidateTemplate {
readonly rpathOwner: string | null;
}
const expandCandidatePath = (
raw: string,
ownerDirectory: string,
context: ProcessContext,
): Expansion =>
expandPrefix(raw, {
ownerDirectory,
executable: context.executable,
rootRelativePath: (absolutePath) =>
context.view.rootRelativePath(absolutePath),
});
const appendRpath = (base: string, rest: string): string => {
if (base === "") return rest;
if (base === "/") return `/${rest}`;
return `${base.replace(/\/+$/u, "")}/${rest}`;
};
const candidateTemplates = (
dependency: MachoDependency,
loader: string,
@@ -266,10 +286,7 @@ const candidateTemplates = (
: "literal";
return [
{
expansion: expandPrefix(name, {
ownerDirectory: directoryOf(loader),
executable: context.executable,
}),
expansion: expandCandidatePath(name, directoryOf(loader), context),
source,
rpath: null,
rpathOwner: null,
@@ -280,16 +297,17 @@ const candidateTemplates = (
const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse();
return owners.flatMap((owner) =>
(context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => {
const base = expandPrefix(rpath, {
ownerDirectory: directoryOf(owner),
executable: context.executable,
});
const base = expandCandidatePath(rpath, directoryOf(owner), context);
const lookupPath = base.scope === "target" ? base.lookupPath : undefined;
const expansion: Expansion = {
scope: base.scope,
path:
base.scope === "outside"
? `${base.path.replace(/\/+$/u, "")}/${rest}`
base.scope === "outside" || lookupPath !== undefined
? appendRpath(base.path, rest)
: `${base.path}${base.path === "" ? "" : "/"}${rest}`,
...(lookupPath === undefined
? {}
: { lookupPath: appendRpath(lookupPath, rest) }),
};
return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
}),
@@ -310,7 +328,10 @@ const evaluateCandidate = async (
return { ...base, outcome: "outside-target", resolved_path: null };
if (template.expansion.scope === "undetermined")
return { ...base, outcome: "undetermined", resolved_path: null };
const lookup = await resolveTreePath(context.view, template.expansion.path);
const lookup = await resolveTreePath(
context.view,
template.expansion.lookupPath ?? template.expansion.path,
);
if (lookup.kind === "escapes")
return { ...base, outcome: "escapes-target", resolved_path: null };
if (lookup.kind !== "file")
@@ -9,6 +9,33 @@ import { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js"
const hash = "a".repeat(64);
it("requires resource-root provenance when executable identity is unknown", () => {
const resourceRoot = {
caller_path: "/tmp/Fixture.app",
canonical_path: "/tmp/Fixture.app",
info_plist_path: "/tmp/Fixture.app/Info.plist",
layout: "root" as const,
executable_sha256: null,
};
const result = buildInterfaceBuilderAnalysis({
targetSha256: null,
resourceRoot,
toolVersion: "test",
documents: [],
limits: interfaceBuilderLimitsSchema.parse({}),
});
expect(result.target_sha256).toBeNull();
expect(result.resource_root).toEqual(resourceRoot);
expect(() =>
buildInterfaceBuilderAnalysis({
targetSha256: null,
toolVersion: "test",
documents: [],
limits: interfaceBuilderLimitsSchema.parse({}),
}),
).toThrow();
});
describe("compiled Interface Builder graph projection", () => {
it("normalizes object, hierarchy, action, outlet, and segue facts", () => {
const raw = {
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more