fix(artifacts): reuse current classification and region reads
This commit is contained in:
commit
f6748a511e
|
|
@ -76,6 +76,9 @@ explicit path or endpoint and do not need it.
|
|||
Facts retain all-recorded-events-before-selection scope. Other qlog layouts
|
||||
and PCAP correlation are unsupported.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Packet-field discovery: `inspect_packet_fields` searches installed protocols
|
||||
and field names/types before choosing fields for `inspect_packet_capture`.
|
||||
Query/page the relevant records; registrations do not establish observed traffic.
|
||||
- Recorded packet captures: `inspect_packet_capture` with selected TShark fields,
|
||||
display filters and decode-as rules. Requires Linux, util-linux prlimit and `REA_TSHARK_COMMAND`; see
|
||||
[capture guide](https://github.com/morluto/rea/blob/main/docs/packet-captures.md).
|
||||
|
|
@ -89,6 +92,9 @@ explicit path or endpoint and do not need it.
|
|||
[packet summaries](https://github.com/morluto/rea/blob/main/docs/pcap-analysis.md).
|
||||
Select an exact DNS question for address pages; page exhaustion does not prove
|
||||
network completeness. This reads recordings and does not acquire traffic.
|
||||
- Decoder discovery: `inspect_signal_decoders` searches installed decoder IDs
|
||||
or selected decoder help, including channels and options. Query and page the
|
||||
relevant details before choosing settings; this does not scan hardware.
|
||||
- Recorded logic/signal traces: `decode_signal_recording` with explicit decoder,
|
||||
channel and protocol settings. On Linux with util-linux prlimit, supply sigrok-cli 0.7.2 through
|
||||
`REA_SIGROK_COMMAND`; see [recording guide](https://github.com/morluto/rea/blob/main/docs/signal-recordings.md).
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ names, the native API record, and value-flow summaries. Pseudocode offsets and l
|
|||
code units; use the returned `next_offset` for continuation. Provider limitations
|
||||
and unavailable facts remain explicit; the complete parent Evidence is retained.
|
||||
|
||||
`inspect_macho` on a large framework returns megabytes of imports and exports.
|
||||
Select `detail: "summary"` for slice identity, UUID and per-collection counts,
|
||||
then page rows with `view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`
|
||||
and `normalized_result.parent_evidence_id`. Facets are entrypoints,
|
||||
architectures, build_metadata, load_commands, dependencies, imports, exports,
|
||||
segments and provenance. `source_exhaustive: false` means unreturned symbols
|
||||
may exist.
|
||||
|
||||
To see which functions or Objective-C methods a Mach-O actually calls in one
|
||||
run, use `observe_native_calls` with explicit breakpoints and a bounded
|
||||
`duration_ms`/`max_events`. It launches a new process under LLDB, so the target
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
- run: sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y tshark util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/captures/fieldDiscovery.test.ts src/process/LinuxResourceLimits.test.ts tests/boundary/captures/captureProvider.test.ts tests/boundary/captures/captureDescriptorCleanup.test.ts tests/boundary/captures/resourceLimits.test.ts tests/boundary/captures/packetOutput.test.ts tests/boundary/mcp/packetCaptureMcp.test.ts tests/boundary/cli/captureCleanup.test.ts
|
||||
- run: node scripts/verify-packet-capture.mjs
|
||||
env:
|
||||
REA_TSHARK_COMMAND: /usr/bin/tshark
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
- run: sudo apt-get update && sudo apt-get install -y sigrok-cli util-linux python3
|
||||
- run: npm ci
|
||||
- run: npm run build:cached
|
||||
- run: npm run test:focused -- src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: npm run test:focused -- tests/boundary/signals/decoderDiscovery.test.ts src/process/LinuxResourceLimits.test.ts src/domain/signals/annotationTextWindow.test.ts tests/boundary/signals/recordingProvider.test.ts tests/boundary/signals/recordingDescriptorCleanup.test.ts tests/boundary/signals/resourceLimits.test.ts tests/boundary/mcp/signalRecordingMcp.test.ts tests/boundary/cli/signalCleanup.test.ts
|
||||
- run: node scripts/verify-signal-recording.mjs
|
||||
env:
|
||||
REA_SIGROK_COMMAND: /usr/bin/sigrok-cli
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ def project_record(state, secrets):
|
|||
safe_bytes, credential_url = transport_value(value, path, transport_url)
|
||||
content_base64 = base64.b64encode(value).decode("ascii")
|
||||
digest = hashlib.sha256(value).hexdigest()
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets)
|
||||
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
|
||||
safe_text = None
|
||||
try:
|
||||
safe_text = safe_bytes.decode("utf-8", errors="strict")
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ contains the complete graph and an `ines` object:
|
|||
`source_location.length` in the original container. Follow `artifact_id` to
|
||||
the node's observed SHA-256 and byte count.
|
||||
- `ines.reconstruction`: ordered region paths, concatenated byte count,
|
||||
SHA-256 and `verified: true`. REA streams the actual region bytes in physical
|
||||
order and compares their concatenation with the original whole-file digest.
|
||||
SHA-256 and `verified: true`. REA accumulates the concatenation digest during
|
||||
the same physical-order region scan that produces node hashes, then compares
|
||||
it with the original whole-file digest. Final source freshness is also checked.
|
||||
No reconstructed file is written during inspection.
|
||||
|
||||
Regions are `header.bin`, optional `trainer.bin` (512 bytes),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ Hopper installation or any file write. Declining or pressing Ctrl-C makes no
|
|||
changes. Agent configuration writes preserve unrelated entries and comments,
|
||||
create backups, use atomic replacement, and verify their result. Setup and
|
||||
uninstall retain an existing `.rea.backup` rather than replacing the first snapshot.
|
||||
If that snapshot differs from the current configuration, setup previews and
|
||||
preserves a verified `.rea.backup.<sha256>.recovery` copy of the current bytes.
|
||||
Both snapshots remain available; a failed rollback reports the current recovery
|
||||
copy's path and the restore failure.
|
||||
Setup also retains valid environment overrides in REA's existing server entry,
|
||||
such as `REA_MCP_INPUT_SCHEMA_PROFILE` and `REA_MCP_MAX_RESPONSE_BYTES`.
|
||||
Detected provider settings replace only matching environment keys.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ artifact digest and structural graph ID. Semantic tracing requires the semantic
|
|||
relation graph and reports when it is unavailable rather than treating missing
|
||||
data as an empty graph.
|
||||
|
||||
Explicit TypeScript `import type` and `export type` declarations remain source
|
||||
facts and do not create runtime import or export relationships. Type export names
|
||||
are reported separately from value export names. Inline `type` specifiers can
|
||||
leave an empty import or re-export with side effects under
|
||||
[`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/verbatimModuleSyntax.html);
|
||||
REA retains those dependency candidates and reports the unavailable compiler
|
||||
options rather than assuming the dependency is erased.
|
||||
|
||||
The semantic graph wire format stores shared non-location provenance once in
|
||||
its required `evidence_contexts` table. Each node, relation, fingerprint, and
|
||||
unknown retains its exact `evidence.location` and names the owning context with
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ after target content was read.
|
|||
|
||||
## Retained application Evidence inputs
|
||||
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`, and
|
||||
`inspect_wasm_artifact` accept `detail`:
|
||||
`analyze_javascript_application`, `inspect_binary_layout`, `inspect_artifact`,
|
||||
`inspect_wasm_artifact`, and `inspect_macho` accept `detail`:
|
||||
`complete` (default) returns the complete analysis Evidence; `summary` records
|
||||
that complete Evidence in the session and returns the derived
|
||||
`inspect_analysis_view` summary Evidence instead. Its
|
||||
|
|
@ -218,6 +218,19 @@ attempting oversized delivery, and returns the same summary with an exact
|
|||
`parent_evidence_id` for later export or facet reads. Delivery detail does not
|
||||
reach WABT or change the original source Evidence ID.
|
||||
|
||||
Completed `inspect_macho` Evidence supports a summary and
|
||||
`view: {kind: "macho", facet: "exports", offset: 0, limit: 64}`. Facets are
|
||||
`entrypoints`, `architectures`, `build_metadata`, `load_commands`,
|
||||
`dependencies`, `imports`, `exports`, `segments`, and `provenance`; pages return
|
||||
the original rows. The summary keeps the selected slice, header fields and UUID,
|
||||
and replaces each collection with its returned count, reported total and
|
||||
`exhaustive` flag. Each page repeats the paged collection's `source_total` and
|
||||
`source_exhaustive`; exhausting a non-exhaustive collection does not establish
|
||||
that unreturned symbols are absent. Large frameworks return megabytes of
|
||||
symbols (Electron Framework: 2.5 MB of imports and exports, 3.3 KB as a
|
||||
summary), so select `detail: "summary"` on the first call. Delivery detail
|
||||
never reaches the native tools or changes the source Evidence ID.
|
||||
|
||||
`trace_application_feature`, `trace_javascript_semantics`,
|
||||
`compare_application_versions`, `compare_source_to_bundle`, and
|
||||
`compare_javascript_export_shapes` accept complete inline application Evidence
|
||||
|
|
|
|||
|
|
@ -128,3 +128,49 @@ Each provider admits one inspection at a time, including acquisition and cleanup
|
|||
An occupied provider returns a retryable capacity error before acquiring another
|
||||
file or worker. Closing the provider stops new admission and drains accepted work;
|
||||
failed cleanup remains owned and can be retried.
|
||||
|
||||
## Discover protocols and field names
|
||||
|
||||
`inspect_packet_fields` / `inspect-packet-fields` queries the installed TShark
|
||||
registration glossary before selecting fields for capture inspection:
|
||||
|
||||
```sh
|
||||
rea inspect-packet-fields --query usb
|
||||
rea inspect-packet-fields --kind fields --protocol usb --query idVendor
|
||||
rea inspect-packet-fields --kind fields --protocol udp --all
|
||||
```
|
||||
|
||||
`kind` defaults to `protocols`; `fields` selects field definitions. `protocol`
|
||||
selects an exact protocol abbreviation (the protocol itself or its child fields).
|
||||
`query` is a case-insensitive literal substring in name, abbreviation or field
|
||||
description. `offset` skips matching records; `limit` defaults to 50. MCP
|
||||
`limit: null` or CLI `--all` requests all remaining matches within the resource
|
||||
budget. `--all` and `--limit` conflict. The window reports examined records of
|
||||
the selected kind before filters, matching records, returned records and
|
||||
`has_more`; it never counts capture packets.
|
||||
|
||||
Returned records preserve field type, parent protocol, display base, bitmask,
|
||||
description and original glossary line number. Empty producer strings remain
|
||||
empty; protocol records have null field-only properties. Repeated abbreviations
|
||||
remain separate registrations. A listed field is an installation capability, not
|
||||
proof that the field appears in a selected recording.
|
||||
|
||||
The adapter uses the documented `tshark -G fields` grammar under its existing
|
||||
private empty profile and Linux process limits. TShark 4.6.9's full glossary was
|
||||
measured at 22,303,197 bytes; discovery admits at most **32 MiB** combined command
|
||||
output (capture inspection remains at **4 MiB**). Parsing walks lines once and
|
||||
constructs objects only for the selected page. Returned strings are copied so
|
||||
retained pages do not retain the complete glossary string. Before constructing a selected
|
||||
record it charges six times the source UTF-8 bytes plus 512 bytes against an
|
||||
**8 MiB** representation budget, covering JSON escaping and record bookkeeping.
|
||||
If that budget would be exceeded, narrow the query or page size; results are not
|
||||
silently truncated. This is a conservative admission budget, not measured heap
|
||||
usage. Parent pipe buffers and producer per-process limits remain distinct.
|
||||
|
||||
Tool/limiter identity, version, stderr and selected arguments are retained in
|
||||
Evidence. No capture, interface or device is opened. Discovery shares capture
|
||||
analysis's admission slot and cleanup owner. Each call rereads the glossary;
|
||||
installed plugins, libraries and global configuration remain trusted and are not
|
||||
fully fingerprinted. There is no cache or snapshot-consistent pagination claim.
|
||||
The real Linux lane checks public CLI/MCP queries against the direct glossary,
|
||||
including USB vendor-ID typing, negative queries and paging.
|
||||
|
|
|
|||
|
|
@ -106,3 +106,37 @@ One decode is admitted at a time, including acquisition and cleanup. Excess
|
|||
requests receive a retryable capacity error before acquiring another file or
|
||||
worker. Shutdown closes admission and drains accepted work; failed cleanup
|
||||
remains owned and retryable.
|
||||
|
||||
## Discover decoders before choosing settings
|
||||
|
||||
`inspect_signal_decoders` / `inspect-signal-decoders` queries the **installed**
|
||||
sigrok catalog without opening a recording or scanning devices:
|
||||
|
||||
```sh
|
||||
rea inspect-signal-decoders --query uart
|
||||
rea inspect-signal-decoders --decoder uart --query baud
|
||||
rea inspect-signal-decoders --decoder i2c --offset 0 --limit 50
|
||||
```
|
||||
|
||||
Omit `decoder` to search decoder IDs and names. Supply one exact decoder ID to
|
||||
read its literal help, including required/optional channels, options, annotation
|
||||
classes and documentation. `query` is a case-insensitive literal substring;
|
||||
matching help retains original one-based line numbers. Empty query selects all.
|
||||
`offset` skips matches; `limit` defaults to 50. MCP `limit: null` or CLI `--all`
|
||||
returns all remaining matches; CLI `--all` and `--limit` conflict.
|
||||
The window reports examined, matching and returned records and `has_more`.
|
||||
A filtered empty page is not evidence that the installation has no decoders.
|
||||
|
||||
Discovery refuses more than 65,535 line separators before constructing record
|
||||
objects (65,536-line representation budget), in addition to the 4 MiB output cap.
|
||||
Exceeding that budget is a resource failure with captured output. Returned text
|
||||
is copied so a retained page does not keep the complete producer output alive.
|
||||
|
||||
Discovery shares decoding's owned process, admission slot, resource limits,
|
||||
cleanup retries and Evidence delivery. It records the executable and limiter
|
||||
identities, version banner and warnings. Failed version checks and malformed
|
||||
producer output preserve diagnostics. Help is producer text, not an independently
|
||||
validated parameter schema. Each request probes again; it does not cache or claim
|
||||
stable pages across installation changes. Libraries/decoder modules remain
|
||||
trusted and are not fully fingerprinted. Catalog output includes a SHA-256 of
|
||||
its executable, not a digest of the complete installation.
|
||||
|
|
|
|||
|
|
@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
|
|||
Process creation assigns a suspended child atomically to an owned Job Object.
|
||||
Membership and kill-on-owner-close policy are verified before resume.
|
||||
Breakaway is disabled. Only the child's default file owner is normalized;
|
||||
the caller token stays unchanged. Caller CPU affinity and priority are
|
||||
preserved. Close, cancellation, timeout, startup failure, and provider failure
|
||||
the caller token stays unchanged. Caller priority is preserved, and caller CPU
|
||||
affinity whenever the caller and the child share a processor group. A child that
|
||||
Windows places in another group keeps that group's default when the caller asked
|
||||
for no bound of its own; a caller pinned to fewer processors than its group
|
||||
holds is refused there rather than widened.
|
||||
Close, cancellation, timeout, startup failure, and provider failure
|
||||
terminate the job and verify all members have settled before runtime cleanup.
|
||||
Cleanup removes admitted objects by handle and unlinks reparse entries without
|
||||
following their targets. Abrupt owner death kills the job but can leave
|
||||
|
|
|
|||
|
|
@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
|
|||
limits only for its JavaScript helpers when running on Bun, which ignores
|
||||
their V8 heap flags; Node.js launches are unchanged.
|
||||
- Only the suspended child's default file owner is normalized. Caller token
|
||||
privileges and ownership remain unchanged. Caller CPU affinity and process
|
||||
priority are preserved in the child. Environment names are sorted and
|
||||
deduplicated using Windows ordinal comparison, preserving their native Unicode
|
||||
semantics and caller-selected values.
|
||||
privileges and ownership remain unchanged. Caller process priority is
|
||||
preserved in the child, and caller CPU affinity whenever both occupy the same
|
||||
processor group. Windows chooses a new process's group itself and rejects a
|
||||
mask belonging to another group, so a child placed elsewhere keeps that
|
||||
group's default when the caller asked for no bound of its own. A caller
|
||||
deliberately pinned to fewer processors than its group holds is a constraint
|
||||
such a child cannot express, and is refused rather than widened to that
|
||||
group's default. A mask covering its group's whole active set does not
|
||||
distinguish a caller Windows placed in that group from one that selected the
|
||||
group deliberately, so such a child can still be placed elsewhere: its
|
||||
processor count stays bounded, its group placement is not preserved, and
|
||||
neither that case nor the refusal above has a verification lane, because both
|
||||
need a host whose processors span more than one group.
|
||||
Environment names are sorted and deduplicated using Windows
|
||||
ordinal comparison, preserving their native Unicode semantics and
|
||||
caller-selected values.
|
||||
- Cleanup walks locked directory objects, removes files by handle, and unlinks
|
||||
reparse entries without traversing their targets. Failed removal retains the
|
||||
root and ancestor leases for retry; a partially removed runtime accepts only
|
||||
|
|
|
|||
|
|
@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
|
|||
return limit;
|
||||
}
|
||||
|
||||
// An affinity mask is meaningful only inside one processor group, so only a
|
||||
// process occupying exactly one group has a group a child can inherit. A
|
||||
// process whose threads span groups, or whose membership cannot be read, has no
|
||||
// single group.
|
||||
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
|
||||
USHORT count = 1;
|
||||
std::array<USHORT, 1> groups{};
|
||||
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
|
||||
group = groups[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
|
||||
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
|
||||
HANDLE rawRead, rawWrite;
|
||||
|
|
@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
|
|||
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
|
||||
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
|
||||
"Set suspended process file owner failed", command);
|
||||
// A mask names processors inside one processor group, and Windows assigns a
|
||||
// new process to a group of its own choosing, so a mask belonging to another
|
||||
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
|
||||
// mask is its group's whole active set, asks for no bound the child's own
|
||||
// group cannot give, so such a child keeps that group's default and the spawn
|
||||
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
|
||||
// another group cannot express, and silently widening it would hand the
|
||||
// provider tree the whole group, so that spawn fails naming the constraint.
|
||||
DWORD_PTR affinity = 0, systemAffinity = 0;
|
||||
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
|
||||
"Read caller process affinity failed", command);
|
||||
if (affinity != 0)
|
||||
USHORT callerGroup = 0, childGroup = 0;
|
||||
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
|
||||
singleProcessorGroup(result->process.get(), childGroup) &&
|
||||
callerGroup == childGroup;
|
||||
if (affinity != 0 && sharedGroup)
|
||||
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
|
||||
else
|
||||
require(affinity == 0 || affinity == systemAffinity,
|
||||
"Caller CPU affinity cannot bound an owned process in another processor group", command,
|
||||
ERROR_INVALID_PARAMETER);
|
||||
const DWORD priority = GetPriorityClass(GetCurrentProcess());
|
||||
require(priority != 0 && SetPriorityClass(result->process.get(), priority),
|
||||
"Preserve caller process priority failed", command);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,20 @@ class Generator:
|
|||
assert "ows-secret" not in json.dumps(projected), projected
|
||||
if isinstance(value, bytes):
|
||||
assert projected["binary_fields"][-1]["state"] == "redacted", projected
|
||||
body = b"A" * 12345
|
||||
state = {"response": {"content": body}}
|
||||
control = adapter.project_record(state, [])
|
||||
field = control["binary_fields"][0]
|
||||
assert field["state"] == "retained" and field["bytes"] == len(body), control
|
||||
for literal in ["12345", "2345"]:
|
||||
# Only the generated byte count matches this exclusion.
|
||||
assert literal not in control["reported"]["response"]["content"]
|
||||
assert literal not in field["content_base64"] and literal not in field["sha256"]
|
||||
projected = adapter.project_record(state, [literal])
|
||||
assert projected["reported"]["response"]["content"] is None, projected
|
||||
assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected
|
||||
assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected
|
||||
assert literal not in json.dumps(projected), projected
|
||||
(root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2}))
|
||||
first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80)))
|
||||
first.id = "original-producer-id"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
import { readFile, writeFile, readdir, mkdir, rm } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
|
@ -91,6 +92,20 @@ const source = pcap(1, [
|
|||
// LINKTYPE_BLUETOOTH_HCI_H4 = 187; H4 command packet, HCI Reset opcode 0x0c03, no parameters.
|
||||
const bluetoothSource = pcap(187, [Buffer.from("01030c00", "hex")]);
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await writeFile(bluetooth, bluetoothSource);
|
||||
|
|
@ -121,14 +136,16 @@ try {
|
|||
.digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_TSHARK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_TSHARK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.configuration, "private-empty-personal-profile");
|
||||
const flags = Object.entries(input)
|
||||
|
|
@ -313,6 +330,133 @@ try {
|
|||
true,
|
||||
);
|
||||
cases++;
|
||||
const discovery = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_packet_fields",
|
||||
);
|
||||
assert.ok(discovery?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discovery.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discovery.outputSchema));
|
||||
const nativeProfile = join(root.path, "native-profile");
|
||||
await mkdir(nativeProfile);
|
||||
const native = (
|
||||
await execute(process.env.REA_TSHARK_COMMAND, ["-G", "fields"], {
|
||||
env: { ...environment, WIRESHARK_CONFIG_DIR: nativeProfile },
|
||||
timeout: 30000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
await rm(nativeProfile, { recursive: true });
|
||||
const nativeLines = native.split(/\r?\n/);
|
||||
if (nativeLines.at(-1) === "") nativeLines.pop();
|
||||
for (const input of [
|
||||
{ query: "usb" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
{ kind: "fields", protocol: "udp", limit: null },
|
||||
{ kind: "fields", query: "rea_no_such_field" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discovery.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discovery.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
const kind = input.kind ?? "protocols";
|
||||
const query = (input.query ?? "").toLowerCase();
|
||||
const expectedMatches = nativeLines.flatMap((line, index) => {
|
||||
const columns = line.split("\t");
|
||||
if ((kind === "protocols") !== (columns[0] === "P")) return [];
|
||||
if (
|
||||
input.protocol !== undefined &&
|
||||
(columns[0] === "P" ? columns[2] : columns[4]) !== input.protocol
|
||||
)
|
||||
return [];
|
||||
if (
|
||||
![
|
||||
columns[1],
|
||||
columns[2],
|
||||
...(columns[0] === "F" ? [columns[7]] : []),
|
||||
].some((value) => value.toLowerCase().includes(query))
|
||||
)
|
||||
return [];
|
||||
return [{ sourceLine: index + 1, columns }];
|
||||
});
|
||||
const offset = input.offset ?? 0;
|
||||
const limit = input.limit === null ? undefined : (input.limit ?? 50);
|
||||
const expectedPage = expectedMatches.slice(
|
||||
offset,
|
||||
limit === undefined ? undefined : offset + limit,
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.records.map((record) => record.source_line),
|
||||
expectedPage.map((record) => record.sourceLine),
|
||||
);
|
||||
assert.deepEqual(report.output_window, {
|
||||
examined: nativeLines.filter((line) =>
|
||||
line.startsWith(kind === "protocols" ? "P\t" : "F\t"),
|
||||
).length,
|
||||
matching: expectedMatches.length,
|
||||
returned: expectedPage.length,
|
||||
has_more:
|
||||
Math.max(0, expectedMatches.length - offset) > expectedPage.length,
|
||||
});
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discovery) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
for (const record of report.records) {
|
||||
const columns = nativeLines[record.source_line - 1].split("\t");
|
||||
assert.equal(record.name, columns[1]);
|
||||
assert.equal(record.abbreviation, columns[2]);
|
||||
assert.equal(record.field_type, columns[0] === "F" ? columns[3] : null);
|
||||
assert.equal(
|
||||
record.parent_protocol,
|
||||
columns[0] === "F" ? columns[4] : null,
|
||||
);
|
||||
assert.equal(record.description, columns[0] === "F" ? columns[7] : null);
|
||||
assert.equal(record.display_base, columns[0] === "F" ? columns[5] : null);
|
||||
assert.equal(record.bitmask, columns[0] === "F" ? columns[6] : null);
|
||||
}
|
||||
if (input.query === "idVendor")
|
||||
assert.ok(
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.abbreviation === "usb.idVendor" &&
|
||||
row.field_type === "FT_UINT16",
|
||||
),
|
||||
);
|
||||
if (input.query === "usb")
|
||||
assert.ok(report.records.some((row) => row.abbreviation === "usb"));
|
||||
if (input.query === "rea_no_such_field")
|
||||
assert.equal(report.output_window.matching, 0);
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-packet-fields", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 16 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"bluetooth.pcap",
|
||||
|
|
@ -329,6 +473,9 @@ console.log(
|
|||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh TShark processes; tokenx estimates include one advertised tool/request/response, not billed usage. Model-driven selection, CPU and peak process-family memory are unmeasured; no cache/speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real TShark CLI/MCP; synthetic Ethernet/IPv4/UDP/DNS and Bluetooth HCI PCAP; no hardware acquisition",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile, readdir } from "node:fs/promises";
|
||||
|
|
@ -68,6 +69,20 @@ for (const byte of Buffer.from("REA")) {
|
|||
lines.push(`#${tick + 1000}`, "1!");
|
||||
const source = lines.join("\n") + "\n";
|
||||
let cases = 0;
|
||||
const discoveryMeasurements = [];
|
||||
const inheritedAddressSpace = (await readFile("/proc/self/limits", "utf8"))
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Max address space "))
|
||||
?.trim()
|
||||
.match(/^Max address space\s+(\S+)\s+(\S+)\s+bytes$/u);
|
||||
assert.ok(inheritedAddressSpace);
|
||||
const expectedAddressSpace = Math.min(
|
||||
1073741824,
|
||||
...inheritedAddressSpace
|
||||
.slice(1)
|
||||
.map((value) => (value === "unlimited" ? Infinity : Number(value))),
|
||||
);
|
||||
|
||||
try {
|
||||
await writeFile(path, source);
|
||||
await client.connect(transport);
|
||||
|
|
@ -104,14 +119,16 @@ try {
|
|||
createHash("sha256").update(source).digest("hex"),
|
||||
);
|
||||
assert.equal(report.hardware_acquisition, "not-performed");
|
||||
assert.equal(report.limits.address_space_bytes, 1073741824);
|
||||
assert.equal(report.limits.address_space_bytes, expectedAddressSpace);
|
||||
assert.equal(report.tool.configured_path, process.env.REA_SIGROK_COMMAND);
|
||||
assert.equal(
|
||||
report.resource_limiter.configured_path,
|
||||
process.env.REA_SIGROK_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
|
||||
);
|
||||
assert.ok(
|
||||
report.resource_limiter.arguments.includes("--as=1073741824:1073741824"),
|
||||
report.resource_limiter.arguments.includes(
|
||||
`--as=${expectedAddressSpace}:${expectedAddressSpace}`,
|
||||
),
|
||||
);
|
||||
assert.equal(report.diagnostics, "");
|
||||
const flags = Object.entries(input)
|
||||
|
|
@ -245,6 +262,88 @@ try {
|
|||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
cases++;
|
||||
const discoveryTool = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "inspect_signal_decoders",
|
||||
);
|
||||
assert.ok(discoveryTool?.outputSchema);
|
||||
assert.ok(ajv.validateSchema(discoveryTool.inputSchema));
|
||||
assert.ok(ajv.validateSchema(discoveryTool.outputSchema));
|
||||
const nativeHelp = (
|
||||
await execute(
|
||||
process.env.REA_SIGROK_COMMAND,
|
||||
["--dont-scan", "--protocol-decoders=uart", "--show"],
|
||||
{ env: environment, timeout: 30000, maxBuffer: 4 * 1024 * 1024 },
|
||||
)
|
||||
).stdout.split(/\r?\n/);
|
||||
if (nativeHelp.at(-1) === "") nativeHelp.pop();
|
||||
for (const input of [
|
||||
{ query: "uart" },
|
||||
{ limit: 1 },
|
||||
{ offset: 1, limit: 2 },
|
||||
{ decoder: "uart", query: "BaUd" },
|
||||
{ decoder: "uart", offset: 50, limit: null },
|
||||
{ query: "rea_no_such_decoder" },
|
||||
]) {
|
||||
const started = performance.now();
|
||||
const response = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
assert.ok(
|
||||
ajv.validate(discoveryTool.outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
);
|
||||
const evidence = parseEvidence(response.structuredContent);
|
||||
const report = evidence.normalized_result;
|
||||
discoveryMeasurements.push({
|
||||
input,
|
||||
elapsed_ms: +(performance.now() - started).toFixed(2),
|
||||
response_bytes: Buffer.byteLength(JSON.stringify(response)),
|
||||
estimated_tokens_one_tool_request_response: estimateTokenCount(
|
||||
JSON.stringify(discoveryTool) +
|
||||
JSON.stringify(input) +
|
||||
JSON.stringify(response),
|
||||
),
|
||||
});
|
||||
if (input.decoder) {
|
||||
const matched = nativeHelp
|
||||
.map((text, i) => ({ line_number: i + 1, text }))
|
||||
.filter(({ text }) =>
|
||||
text.toLowerCase().includes((input.query ?? "").toLowerCase()),
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.help_lines,
|
||||
matched.slice(
|
||||
input.offset ?? 0,
|
||||
input.limit === null
|
||||
? undefined
|
||||
: (input.offset ?? 0) + (input.limit ?? 50),
|
||||
),
|
||||
);
|
||||
} else if (input.query === "uart")
|
||||
assert.ok(report.decoders.some(({ id }) => id === "uart"));
|
||||
const flags = Object.entries(input).flatMap(([key, value]) =>
|
||||
value === null ? ["--all"] : [`--${key}`, String(value)],
|
||||
);
|
||||
const cli = JSON.parse(
|
||||
(
|
||||
await execute(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-signal-decoders", ...flags, "--format", "json"],
|
||||
{ env: environment, timeout: 60000, maxBuffer: 8 * 1024 * 1024 },
|
||||
)
|
||||
).stdout,
|
||||
);
|
||||
assert.deepEqual(cli.normalized_result, report);
|
||||
cases++;
|
||||
}
|
||||
const unavailable = await client.callTool({
|
||||
name: discoveryTool.name,
|
||||
arguments: { decoder: "rea_no_such_decoder" },
|
||||
});
|
||||
assert.equal(unavailable.isError, true);
|
||||
cases++;
|
||||
await client.close();
|
||||
assert.deepEqual((await readdir(root.path)).sort(), [
|
||||
"paged.vcd",
|
||||
|
|
@ -260,6 +359,9 @@ console.log(
|
|||
{
|
||||
status: "passed",
|
||||
cases,
|
||||
discovery_measurements: discoveryMeasurements,
|
||||
measurement_scope:
|
||||
"Warm MCP server, fresh sigrok processes; tokenx estimates include one advertised tool/request/response, not billed model usage. No model-driven selection benchmark, CPU or peak process-family memory measurement; no cache or speedup claim.",
|
||||
host: `${process.platform}/${process.arch}`,
|
||||
scope:
|
||||
"real sigrok UART VCD through compiled CLI/MCP; no physical acquisition",
|
||||
|
|
|
|||
|
|
@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
|
|||
unicodeNamesPreserved: true,
|
||||
});
|
||||
report.processes.windowsEnvironmentSemantics = true;
|
||||
// Observed through this process's own id: a separate probe process would
|
||||
// get its own processor group, so it cannot stand in for the process that
|
||||
// spawns the child.
|
||||
const callerAffinity = JSON.parse(
|
||||
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
|
||||
encoding: "utf8",
|
||||
}),
|
||||
);
|
||||
const affinityChild = native.call("process_spawn", [
|
||||
boundaryFixture,
|
||||
`${quote(boundaryFixture)} affinity`,
|
||||
workspace,
|
||||
environment,
|
||||
]);
|
||||
const observedAffinity = await wait(affinityChild);
|
||||
native.call("process_close", [affinityChild.handle]);
|
||||
assert.equal(observedAffinity.exitCode, 0);
|
||||
const childAffinity = JSON.parse(observedAffinity.output);
|
||||
// Masks cross as hexadecimal strings because a mask can exceed the
|
||||
// safe-integer range. Sharing one processor group makes the caller's mask
|
||||
// applicable, so it must be inherited exactly. Windows placing the child in
|
||||
// another group is only permitted when the caller asked for no bound of its
|
||||
// own, which its group's whole active set establishes; a narrower mask has
|
||||
// to fail the spawn rather than widen to that group's default.
|
||||
const sharedGroup =
|
||||
callerAffinity.singleProcessorGroup &&
|
||||
childAffinity.singleProcessorGroup &&
|
||||
callerAffinity.processorGroup === childAffinity.processorGroup;
|
||||
if (sharedGroup)
|
||||
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
|
||||
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
|
||||
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
|
||||
report.callerAffinity = {
|
||||
...callerAffinity,
|
||||
sharedGroupWithChild: sharedGroup,
|
||||
};
|
||||
}
|
||||
const ownerFixture = join(workspace, "owner.mjs");
|
||||
const ownerPidFile = join(workspace, "crash-child.pid");
|
||||
|
|
|
|||
|
|
@ -84,6 +84,24 @@ describe("parseAdbDevicesOutput", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("accepts complete multiword states and rejects state prefixes", () => {
|
||||
const output = [
|
||||
"serial-no-permission no permissions usb:1-1",
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
].join("\n");
|
||||
const { devices, unparsedLines } = parseAdbDevicesOutput(output);
|
||||
expect(devices.map(({ serial, state }) => [serial, state])).toEqual([
|
||||
["serial-no-permission", "no permissions"],
|
||||
]);
|
||||
expect(unparsedLines).toEqual([
|
||||
"serial-bad-device deviceXYZ ",
|
||||
"serial-bad-offline offline-ish",
|
||||
"serial-bad-auth unauthorizedXYZ",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns unrecognized lines instead of dropping them", () => {
|
||||
const output = [
|
||||
"List of devices attached",
|
||||
|
|
@ -194,10 +212,10 @@ describe("parsePackageListOutput", () => {
|
|||
|
||||
it("keeps unrecognized lines for partial coverage reporting", () => {
|
||||
const { packages, unparsedLines } = parsePackageListOutput(
|
||||
"package:/data/app/x/base.apk=com.ok\ngarbage line\n",
|
||||
"package:/data/app/x/base.apk=com.ok\n garbage line \n",
|
||||
);
|
||||
expect(packages).toHaveLength(1);
|
||||
expect(unparsedLines).toEqual(["garbage line"]);
|
||||
expect(unparsedLines).toEqual([" garbage line "]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ import {
|
|||
adbHostFileName,
|
||||
type AdbHostFileNameReason,
|
||||
} from "./AdbHostFileName.js";
|
||||
import {
|
||||
ADB_DEVICE_STATES,
|
||||
type AdbDeviceState,
|
||||
} from "../domain/adb/adbDeviceAnalysis.js";
|
||||
|
||||
/** One device exactly as `adb devices -l` reports it. */
|
||||
export interface AdbDeviceEntry {
|
||||
readonly serial: string;
|
||||
readonly state: string;
|
||||
readonly state: AdbDeviceState;
|
||||
readonly transport: "usb" | "tcp" | "unknown";
|
||||
readonly product: string | null;
|
||||
readonly model: string | null;
|
||||
|
|
@ -24,25 +28,6 @@ export interface AdbDeviceEntry {
|
|||
readonly kind_basis: "emulator_serial_prefix" | "usb_transport" | "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Device states adb prints after the serial, longest first so two-word
|
||||
* states match before their single-word prefixes. A line whose remainder
|
||||
* starts with none of them is not a device line.
|
||||
*/
|
||||
const DEVICE_STATES = [
|
||||
"no permissions",
|
||||
"unauthorized",
|
||||
"authorizing",
|
||||
"offline",
|
||||
"recovery",
|
||||
"sideload",
|
||||
"connecting",
|
||||
"bootloader",
|
||||
"device",
|
||||
"unknown",
|
||||
"host",
|
||||
] as const;
|
||||
|
||||
const DEVICE_LINE = /^(\S+)\s+(.*)$/u;
|
||||
|
||||
const metadataValue = (remainder: string, key: string): string | null => {
|
||||
|
|
@ -73,11 +58,15 @@ export const parseAdbDevicesOutput = (
|
|||
continue;
|
||||
}
|
||||
const match = DEVICE_LINE.exec(line);
|
||||
const state = DEVICE_STATES.find((candidate) =>
|
||||
match?.[2]?.startsWith(candidate),
|
||||
);
|
||||
const state = ADB_DEVICE_STATES.find((candidate) => {
|
||||
const remainder = match?.[2];
|
||||
if (remainder === undefined || !remainder.startsWith(candidate))
|
||||
return false;
|
||||
const next = remainder[candidate.length];
|
||||
return next === undefined || /\s/u.test(next);
|
||||
});
|
||||
if (match === null || state === undefined) {
|
||||
if (line.trim() !== "") unparsedLines.push(line);
|
||||
if (rawLine.trim() !== "") unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
const serial = match[1]!;
|
||||
|
|
@ -192,7 +181,7 @@ export const parsePackageListOutput = (
|
|||
if (line === "") continue;
|
||||
const match = PACKAGE_LINE.exec(line);
|
||||
if (match === null) {
|
||||
unparsedLines.push(line);
|
||||
unparsedLines.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
packages.push({
|
||||
|
|
|
|||
|
|
@ -121,6 +121,52 @@ fi`,
|
|||
]);
|
||||
});
|
||||
|
||||
it("refuses device listings with an invalid state token", async () => {
|
||||
const malformedLine = "serial deviceXYZ ";
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n${malformedLine}\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await providerOver(binary).execute({
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error._tag).toBe("AnalysisProtocolError");
|
||||
expect(result.error.message).toContain(malformedLine);
|
||||
});
|
||||
|
||||
it("lists rescue and detached transports alongside online devices", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "devices" ]; then
|
||||
printf 'List of devices attached\\n'
|
||||
printf 'serial-online device transport_id:1\\n'
|
||||
printf 'serial-rescue rescue transport_id:2\\n'
|
||||
printf 'serial-detached detached transport_id:3\\n'
|
||||
exit 0
|
||||
fi`,
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "list_adb_devices",
|
||||
input: {},
|
||||
});
|
||||
expect(result.devices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ serial: "serial-online", state: "device" }),
|
||||
expect.objectContaining({ serial: "serial-rescue", state: "rescue" }),
|
||||
expect.objectContaining({
|
||||
serial: "serial-detached",
|
||||
state: "detached",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects a device through the getprop whitelist", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
|
|
@ -580,6 +626,34 @@ it("projects package details from dumpsys package output", async () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Android 10 and 11 append ": restricted=true" to hard- and soft-restricted
|
||||
// entries of the requested-permissions list (Settings.dumpPackageLPr).
|
||||
it("counts requested permissions that the device marks as restricted", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
"dumpsys:package": `Packages:
|
||||
Package [com.example] (abcdef):
|
||||
userId=10234
|
||||
versionName=1.2.3
|
||||
requested permissions:
|
||||
android.permission.INTERNET
|
||||
android.permission.WRITE_EXTERNAL_STORAGE: restricted=true
|
||||
android.permission.CAMERA
|
||||
install permissions:
|
||||
android.permission.INTERNET: granted=true
|
||||
`,
|
||||
}),
|
||||
);
|
||||
const result = await evidenceResult(providerOver(binary), {
|
||||
operation: "inspect_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.example" },
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
requested_permissions_count: 3,
|
||||
coverage: "complete",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an uninstalled package from dumpsys refusal output", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({ "dumpsys:package": "Unable to find package: com.absent\n" }),
|
||||
|
|
|
|||
|
|
@ -236,7 +236,11 @@ export const parseDumpsysPackageOutput = (
|
|||
inPermissions = true;
|
||||
continue;
|
||||
}
|
||||
if (inPermissions && /^\s{2,}[A-Za-z0-9._]+$/.test(line)) {
|
||||
// Android 10 and 11 mark restricted entries as "NAME: restricted=true".
|
||||
if (
|
||||
inPermissions &&
|
||||
/^\s{2,}[A-Za-z0-9._]+(?:: restricted=true)?$/u.test(line)
|
||||
) {
|
||||
permissions += 1;
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,9 +199,11 @@ export class JadxMcpTransport implements Transport {
|
|||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
this.#appendFrame(fragment, frameBytes);
|
||||
if (newline === -1) return;
|
||||
const line = this.#frameBuffer
|
||||
.subarray(0, this.#frameLength)
|
||||
.toString("utf8");
|
||||
const line = new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
// Preserve a leading BOM so the JSON-RPC parser can reject it.
|
||||
ignoreBOM: true,
|
||||
}).decode(this.#frameBuffer.subarray(0, this.#frameLength));
|
||||
this.#frameBuffer = Buffer.alloc(0);
|
||||
this.#frameLength = 0;
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ const workflowAvailabilityFor = ({
|
|||
remediation:
|
||||
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
|
||||
};
|
||||
if (name === "inspect_packet_capture")
|
||||
if (name === "inspect_packet_capture" || name === "inspect_packet_fields")
|
||||
return policy.packetCaptureEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
|
|
@ -405,7 +405,7 @@ const workflowAvailabilityFor = ({
|
|||
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.",
|
||||
};
|
||||
if (name === "decode_signal_recording")
|
||||
if (name === "decode_signal_recording" || name === "inspect_signal_decoders")
|
||||
return policy.signalRecordingEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ import {
|
|||
detectReferenceSourceLanguage,
|
||||
} from "../domain/referenceSourceClassification.js";
|
||||
import type { HistoricalSourceGraphInput } from "../domain/referenceSourceGraph.js";
|
||||
import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js";
|
||||
import {
|
||||
parseReferenceSourceImports,
|
||||
PARSEABLE_REFERENCE_SOURCE_LANGUAGES,
|
||||
} from "../domain/referenceSourceImportParsing.js";
|
||||
import type {
|
||||
ReferenceSourceEntry,
|
||||
ReferenceSourceRead,
|
||||
} from "../reference/ReferenceSourceReaderTypes.js";
|
||||
import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
|
||||
import { typescriptEmittedSourceCandidates } from "./TypeScriptEmittedSourceCandidates.js";
|
||||
|
||||
export interface ParsedReferenceSourceEntries {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,3 @@ export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
|
|||
".coverage",
|
||||
"*.log",
|
||||
] as const;
|
||||
|
||||
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
|
||||
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ export interface RegularFileReadOptions {
|
|||
readonly resources?: ArtifactResourceScope;
|
||||
}
|
||||
|
||||
export class InvalidUtf8FileError extends TypeError {
|
||||
readonly _tag = "InvalidUtf8FileError";
|
||||
constructor(
|
||||
readonly path: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`File is not valid UTF-8: ${path}`, { cause });
|
||||
this.name = this._tag;
|
||||
}
|
||||
}
|
||||
|
||||
export type RegularFileReadOutcome<Value> =
|
||||
| { readonly kind: "completed"; readonly value: Value }
|
||||
| { readonly kind: "failed"; readonly cause: unknown };
|
||||
|
|
@ -142,7 +153,34 @@ const withRegularFileOwned = async <Value>(
|
|||
export const readRegularFile = (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<Buffer> =>
|
||||
): Promise<Buffer> => readRegularFileContent(path, (bytes) => bytes, options);
|
||||
|
||||
/** Read valid UTF-8 without replacing source bytes or stripping an initial BOM. */
|
||||
export const readRegularFileText = (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<string> =>
|
||||
readRegularFileContent(
|
||||
path,
|
||||
(bytes) => {
|
||||
try {
|
||||
return new TextDecoder("utf-8", {
|
||||
fatal: true,
|
||||
ignoreBOM: true,
|
||||
}).decode(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof TypeError)) throw cause;
|
||||
throw new InvalidUtf8FileError(path, cause);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const readRegularFileContent = <Value>(
|
||||
path: string,
|
||||
decode: (bytes: Buffer) => Value,
|
||||
options: RegularFileReadOptions,
|
||||
): Promise<Value> =>
|
||||
withRegularFile(
|
||||
path,
|
||||
async (handle, stats) => {
|
||||
|
|
@ -153,30 +191,7 @@ export const readRegularFile = (
|
|||
);
|
||||
if (bytes === undefined || bytes.length !== stats.size)
|
||||
throw new RegularFileChangedError(path);
|
||||
return bytes;
|
||||
return decode(bytes);
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
/** Read one admitted regular file as UTF-8 without waiting on a pipe. */
|
||||
export const readRegularFileText = async (
|
||||
path: string,
|
||||
options: RegularFileReadOptions = {},
|
||||
): Promise<string> => {
|
||||
try {
|
||||
return (await readRegularFile(path, options)).toString("utf8");
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
cause instanceof RegularFileCleanupFailure &&
|
||||
cause.outcome.kind === "completed" &&
|
||||
Buffer.isBuffer(cause.outcome.value)
|
||||
)
|
||||
throw new RegularFileCleanupFailure(
|
||||
cause.path,
|
||||
cause.owner,
|
||||
{ kind: "completed", value: cause.outcome.value.toString("utf8") },
|
||||
cause.cleanupCause,
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -54,6 +54,40 @@ describe("setup workflow", () => {
|
|||
expect(result.remediation).toContain("could not be updated");
|
||||
});
|
||||
|
||||
it("reports a failed config rollback with its destination and recovery backup", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
host.clients = [{ name: "codex", configPath: "/fixture/codex.json" }];
|
||||
host.clientResults.set("codex", {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
backupPath: "/fixture/codex.json.rea.backup",
|
||||
failure: {
|
||||
kind: "restore",
|
||||
path: "/fixture/codex.json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup(
|
||||
{ ...options(true), clientIds: ["codex"] },
|
||||
host,
|
||||
);
|
||||
|
||||
expect(result.clients.codex).toEqual({
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
backupPath: "/fixture/codex.json.rea.backup",
|
||||
failure: {
|
||||
kind: "restore",
|
||||
path: "/fixture/codex.json",
|
||||
detail: "permission denied",
|
||||
},
|
||||
});
|
||||
expect(result.remediation).toContain("permission denied");
|
||||
expect(result.remediation).toContain("/fixture/codex.json.rea.backup");
|
||||
});
|
||||
|
||||
it("explains skill installation recovery", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
host.hopper = "/Applications/Hopper";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "./ClientConfigurationDocument.js";
|
||||
import { dshDocument, DshCordisDocument } from "./DshCordisPatch.js";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
|
|
@ -29,6 +30,7 @@ import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
|||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import {
|
||||
readRegularFile,
|
||||
readRegularFileText,
|
||||
RegularFileCleanupFailure,
|
||||
} from "./RegularFileRead.js";
|
||||
|
|
@ -40,6 +42,26 @@ import type {
|
|||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
export class ClientConfigurationCleanupRollbackFailure extends RegularFileCleanupFailure<string> {
|
||||
constructor(
|
||||
failure: RegularFileCleanupFailure<string>,
|
||||
readonly configurationFailure: Extract<
|
||||
ClientConfigurationResult,
|
||||
{ readonly status: "failed"; readonly reason: "rollback" }
|
||||
>,
|
||||
) {
|
||||
super(
|
||||
failure.path,
|
||||
failure.owner,
|
||||
failure.outcome,
|
||||
new AggregateError(
|
||||
[failure.cleanupCause, new Error(configurationFailure.failure.detail)],
|
||||
`Configuration readback cleanup and rollback failed: ${configurationFailure.failure.path}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Configure one supported client's native stdio MCP registration shape. */
|
||||
export const configureClientConfiguration = (
|
||||
client: SetupClient,
|
||||
|
|
@ -167,11 +189,23 @@ const persistClientConfiguration = async ({
|
|||
}: ClientConfigurationUpdate): Promise<ClientConfigurationResult> => {
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
if (
|
||||
backupPath !== undefined &&
|
||||
!(await preserveConfigBackup(transactionPath, backupPath))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
let recoveryBackupPath: string | undefined;
|
||||
if (backupPath !== undefined && original !== undefined) {
|
||||
const selection = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (!selection.ok) return { status: "failed", reason: "backup" };
|
||||
if (
|
||||
!(await preserveConfigBackup(transactionPath, selection.value.backupPath))
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
const verified = await selectCurrentRecoveryBackup(backupPath, original);
|
||||
if (
|
||||
!verified.ok ||
|
||||
verified.value.backupPath !== selection.value.backupPath ||
|
||||
!verified.value.present
|
||||
)
|
||||
return { status: "failed", reason: "backup" };
|
||||
recoveryBackupPath = verified.value.backupPath;
|
||||
}
|
||||
// A native OpenCode V2 entry replaces REA's legacy V1 entry, which would
|
||||
// otherwise conflict with it.
|
||||
const legacyPath = legacyClientServerPath(
|
||||
|
|
@ -239,19 +273,39 @@ const persistClientConfiguration = async ({
|
|||
!clientConfigurationValuesEqual(readback.servers[name], entry),
|
||||
)
|
||||
) {
|
||||
await restoreConfig(transactionPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (cause instanceof RegularFileCleanupFailure) {
|
||||
const rollback = await restoreConfig(transactionPath, original);
|
||||
if (!rollback.ok)
|
||||
throw new ClientConfigurationCleanupRollbackFailure(cause, {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
failure: rollback.error,
|
||||
});
|
||||
throw cause;
|
||||
}
|
||||
// Readback failure restores the transaction before reporting.
|
||||
void cause;
|
||||
await restoreConfig(transactionPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
return rollbackOrReadbackFailure(
|
||||
transactionPath,
|
||||
original,
|
||||
recoveryBackupPath,
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "configured",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
...(recoveryBackupPath === undefined
|
||||
? {}
|
||||
: { backupPath: recoveryBackupPath }),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -406,9 +460,18 @@ export const inspectClientConfiguration = async (
|
|||
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
||||
};
|
||||
}
|
||||
const backupSelection = await selectCurrentRecoveryBackup(
|
||||
`${client.configPath}.rea.backup`,
|
||||
original,
|
||||
);
|
||||
if (!backupSelection.ok)
|
||||
return {
|
||||
status: "invalid",
|
||||
remediation: backupSelection.error.detail,
|
||||
};
|
||||
return {
|
||||
status: "update",
|
||||
backupPath: `${client.configPath}.rea.backup`,
|
||||
backupPath: backupSelection.value.backupPath,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -473,19 +536,101 @@ const preserveConfigBackup = async (
|
|||
}
|
||||
};
|
||||
|
||||
interface RecoveryBackupSelection {
|
||||
readonly backupPath: string;
|
||||
readonly present: boolean;
|
||||
}
|
||||
|
||||
interface RecoveryBackupSelectionFailure {
|
||||
readonly kind: "unreadable" | "occupied";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
const selectCurrentRecoveryBackup = async (
|
||||
firstBackupPath: string,
|
||||
original: string,
|
||||
): Promise<Result<RecoveryBackupSelection, RecoveryBackupSelectionFailure>> => {
|
||||
const expected = Buffer.from(original, "utf8");
|
||||
let firstBackup: Buffer | undefined;
|
||||
try {
|
||||
firstBackup = await readRegularFile(firstBackupPath, {
|
||||
symlinks: "reject",
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: firstBackupPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (firstBackup === undefined)
|
||||
return ok({ backupPath: firstBackupPath, present: false });
|
||||
if (firstBackup.equals(expected))
|
||||
return ok({ backupPath: firstBackupPath, present: true });
|
||||
|
||||
const digest = createHash("sha256").update(original, "utf8").digest("hex");
|
||||
const recoveryPath = `${firstBackupPath}.${digest}.recovery`;
|
||||
let recovery: Buffer | undefined;
|
||||
try {
|
||||
recovery = await readRegularFile(recoveryPath, { symlinks: "reject" });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileCleanupFailure) throw cause;
|
||||
if (!isMissing(cause))
|
||||
return err({
|
||||
kind: "unreadable",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery backup could not be checked: ${errorDetail(cause)}`,
|
||||
});
|
||||
}
|
||||
if (recovery !== undefined && !recovery.equals(expected))
|
||||
return err({
|
||||
kind: "occupied",
|
||||
path: recoveryPath,
|
||||
detail: `The recovery path already contains different data: ${recoveryPath}`,
|
||||
});
|
||||
return ok({ backupPath: recoveryPath, present: recovery !== undefined });
|
||||
};
|
||||
|
||||
const restoreConfig = async (
|
||||
path: string,
|
||||
original: string | undefined,
|
||||
): Promise<void> => {
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
{ readonly kind: "restore"; readonly path: string; readonly detail: string }
|
||||
>
|
||||
> => {
|
||||
try {
|
||||
if (original === undefined) await rm(path, { force: true });
|
||||
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
||||
return ok(undefined);
|
||||
} catch (cause: unknown) {
|
||||
// The backup remains available for the remediation reported by setup.
|
||||
void cause;
|
||||
return err({ kind: "restore", path, detail: errorDetail(cause) });
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackOrReadbackFailure = async (
|
||||
targetPath: string,
|
||||
original: string | undefined,
|
||||
backupPath: string | undefined,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
const rollback = await restoreConfig(targetPath, original);
|
||||
return rollback.ok
|
||||
? { status: "failed", reason: "readback" }
|
||||
: {
|
||||
status: "failed",
|
||||
reason: "rollback",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
failure: rollback.error,
|
||||
};
|
||||
};
|
||||
|
||||
const errorDetail = (cause: unknown): string =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
/** Whether REA's entry matches, no legacy entry remains, and no client disable list suppresses it. */
|
||||
const registrationCurrent = (
|
||||
parsed: ClientConfigurationDocument,
|
||||
|
|
|
|||
|
|
@ -6,15 +6,19 @@ import type {
|
|||
} from "./SetupTypes.js";
|
||||
import type { SetupClient } from "./SupportedClients.js";
|
||||
|
||||
const failedConfigurationMessage = (
|
||||
reason: "path" | "backup" | "write" | "readback",
|
||||
export const failedConfigurationMessage = (
|
||||
result: Extract<ClientConfigurationResult, { readonly status: "failed" }>,
|
||||
): string => {
|
||||
if (reason === "path")
|
||||
if (result.reason === "path")
|
||||
return "Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup.";
|
||||
if (reason === "backup")
|
||||
if (result.reason === "backup")
|
||||
return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
|
||||
if (reason === "write")
|
||||
if (result.reason === "write")
|
||||
return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
|
||||
if (result.reason === "rollback")
|
||||
return result.backupPath === undefined
|
||||
? `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}; the failed first-run file may remain. Remove or repair it before rerunning setup.`
|
||||
: `Agent configuration rollback failed at ${result.failure.path}: ${result.failure.detail}. Inspect the preserved backup at ${result.backupPath} and restore the intended configuration, then rerun setup.`;
|
||||
return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
|
||||
};
|
||||
|
||||
|
|
@ -40,12 +44,12 @@ export const configureDetectedClients = async (options: {
|
|||
);
|
||||
options.clients[client.name] = result;
|
||||
if (result.status === "failed") {
|
||||
failure ??= failedConfigurationMessage(result.reason);
|
||||
failure ??= failedConfigurationMessage(result);
|
||||
options.onProgress?.({
|
||||
actionId,
|
||||
label,
|
||||
state: "failed",
|
||||
detail: failedConfigurationMessage(result.reason),
|
||||
detail: failedConfigurationMessage(result),
|
||||
});
|
||||
} else if (result.status === "configured") {
|
||||
options.appliedActions.push(`configured_${client.name}`);
|
||||
|
|
|
|||
|
|
@ -33,9 +33,11 @@ import {
|
|||
} from "./SetupSkill.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
ClientConfigurationCleanupRollbackFailure,
|
||||
configureClientConfiguration,
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
import { failedConfigurationMessage } from "./SetupClients.js";
|
||||
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
||||
import {
|
||||
RegularFileCleanupFailure,
|
||||
|
|
@ -275,8 +277,12 @@ const runClientConfigurationOperation = async <Value>(
|
|||
cleanup.outcome.kind === "failed"
|
||||
? cleanup.outcome.cause
|
||||
: cause.cleanupCause;
|
||||
const rollbackMessage =
|
||||
cause instanceof ClientConfigurationCleanupRollbackFailure
|
||||
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
|
||||
: "";
|
||||
throw configurationCleanupError(
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}`,
|
||||
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
|
||||
cause,
|
||||
cleanup.cleanup,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -320,5 +320,5 @@ const clientConfigurationDetail = (
|
|||
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
|
||||
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
|
||||
: "";
|
||||
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
||||
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ export type ClientConfigurationResult =
|
|||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "path" | "backup" | "write" | "readback";
|
||||
}
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason: "rollback";
|
||||
readonly backupPath?: string;
|
||||
readonly failure: {
|
||||
readonly kind: "restore";
|
||||
readonly path: string;
|
||||
readonly detail: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Read-only state used to block unsafe setup plans before any mutation. */
|
||||
|
|
|
|||
|
|
@ -25,7 +25,11 @@ import {
|
|||
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import { dshDocument } from "./DshCordisPatch.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import { readRegularFileText } from "./RegularFileRead.js";
|
||||
import {
|
||||
InvalidUtf8FileError,
|
||||
RegularFileCleanupFailure,
|
||||
readRegularFileText,
|
||||
} from "./RegularFileRead.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import {
|
||||
manualRegistrationRemediation,
|
||||
|
|
@ -269,13 +273,17 @@ const readClientConfiguration = async (
|
|||
try {
|
||||
original = await fileSystem.readText(transactionPath);
|
||||
} catch (cause: unknown) {
|
||||
const readFailure =
|
||||
cause instanceof RegularFileCleanupFailure ? cause.cause : cause;
|
||||
return itemRead(
|
||||
isMissing(cause)
|
||||
? item(client.name, "skipped", "Configuration does not exist.")
|
||||
: item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
readFailure instanceof InvalidUtf8FileError
|
||||
? "Configuration is not valid UTF-8 and was not changed. Repair it, then rerun uninstall."
|
||||
: "Configuration could not be read. Check file permissions, then rerun uninstall.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
inspectPacketFieldsInputSchema,
|
||||
packetFieldsCatalogSchema,
|
||||
type InspectPacketFieldsInput,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
|
|
@ -24,6 +30,10 @@ export interface PacketCapturePort {
|
|||
input: InspectPacketCaptureInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
inspectFields(
|
||||
input: InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -74,14 +84,58 @@ export class PacketCaptureService {
|
|||
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
|
||||
),
|
||||
);
|
||||
const evidence = createEvidence(execution.subject, execution.provider, {
|
||||
operation,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
});
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
async inspectFields(
|
||||
raw: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "inspect_packet_fields";
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const input = inspectPacketFieldsInputSchema.safeParse(raw);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, input.error.issues, raw, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
const decoded = await this.provider.inspectFields(input.data, options);
|
||||
if (!decoded.ok) return decoded;
|
||||
const execution = decoded.value;
|
||||
const report = packetFieldsCatalogSchema.safeParse(execution.result);
|
||||
if (
|
||||
!report.success ||
|
||||
JSON.stringify(report.data.selection) !== JSON.stringify(input.data) ||
|
||||
execution.subject?.path !== report.data.tool.path ||
|
||||
execution.subject.sha256 !== report.data.tool.sha256
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Field discovery returned malformed data or changed the selected installation/options.",
|
||||
),
|
||||
);
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
#evidence(
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
},
|
||||
);
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
|
|
|
|||
|
|
@ -809,6 +809,42 @@ it("keeps an active script message bucket after its last message is evicted", as
|
|||
await manager.closeAll();
|
||||
});
|
||||
|
||||
it("retains the newest contiguous messages across repeated budget evictions", async () => {
|
||||
const { bindings, manager, started } = await startLocalAttachedSession();
|
||||
expect(started.ok).toBe(true);
|
||||
if (!started.ok) return;
|
||||
bindings.device.session.nextPayload = { index: -1 };
|
||||
const loaded = await manager.loadScript(started.value.sessionId, {
|
||||
sourceKind: "inline",
|
||||
source: "send({index: -1});",
|
||||
});
|
||||
expect(loaded.ok).toBe(true);
|
||||
const script = bindings.device.session.scripts[0];
|
||||
expect(script).toBeDefined();
|
||||
if (script === undefined) return;
|
||||
|
||||
// About 500 of these fit the 16 MiB budget, so 2,000 sends evict and
|
||||
// compact the queue several times.
|
||||
const filler = "x".repeat(32_000);
|
||||
for (let index = 0; index < 2_000; index += 1)
|
||||
script.message.emit({ type: "send", payload: { index, filler } }, null);
|
||||
|
||||
const status = manager.status(started.value.sessionId);
|
||||
expect(status?.messagesTruncated).toBe(true);
|
||||
const indexes = (status?.messages ?? []).map(
|
||||
(message) => (message as { payload: { index: number } }).payload.index,
|
||||
);
|
||||
expect(indexes.length).toBeGreaterThan(400);
|
||||
expect(indexes.at(-1)).toBe(1_999);
|
||||
expect(indexes).toEqual(
|
||||
Array.from(
|
||||
{ length: indexes.length },
|
||||
(_, offset) => 2_000 - indexes.length + offset,
|
||||
),
|
||||
);
|
||||
await manager.closeAll();
|
||||
});
|
||||
|
||||
it("serializes a final remote release against a new same-endpoint session", async () => {
|
||||
const bindings = new FakeDeviceManager();
|
||||
let enteredRemoval: (() => void) | undefined;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
lookupExactPackageImport,
|
||||
} from "../../domain/javascript/javascriptPackageImportAliases.js";
|
||||
import { typescriptEmittedSourceCandidates } from "../TypeScriptEmittedSourceCandidates.js";
|
||||
import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js";
|
||||
|
||||
type ArtifactPathResolutionContext =
|
||||
| "package-entrypoint"
|
||||
|
|
@ -166,7 +167,7 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
|
|||
);
|
||||
const isModuleUrlPath =
|
||||
input.context === "module-specifier" &&
|
||||
(input.declaredPath.startsWith(".") ||
|
||||
(isRelativeJavaScriptModuleSpecifier(input.declaredPath) ||
|
||||
input.declaredPath.startsWith("/") ||
|
||||
fileUrlPath(input.declaredPath) !== undefined);
|
||||
const emittedSourceCandidates =
|
||||
|
|
@ -229,7 +230,10 @@ const contextualCandidate = (
|
|||
return unresolvedCandidate("external", [
|
||||
"URL and Node builtin schemes are outside static artifact module resolution.",
|
||||
]);
|
||||
if (!declared.startsWith(".") && !declared.startsWith("/"))
|
||||
if (
|
||||
!isRelativeJavaScriptModuleSpecifier(declared) &&
|
||||
!declared.startsWith("/")
|
||||
)
|
||||
return bareModuleCandidate(input);
|
||||
if (input.moduleKind !== "require") {
|
||||
const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared));
|
||||
|
|
@ -275,7 +279,10 @@ const contextualCandidate = (
|
|||
"URL",
|
||||
);
|
||||
}
|
||||
return relativeCandidate(input.sourcePath, declared);
|
||||
const candidate = relativeCandidate(input.sourcePath, declared);
|
||||
return context === "module-specifier" && input.moduleKind === "import"
|
||||
? exactUrlPathCandidate(declared, candidate, "module URL")
|
||||
: candidate;
|
||||
};
|
||||
|
||||
const decodeModuleUrlPath = (
|
||||
|
|
@ -310,9 +317,12 @@ const bareModuleCandidate = (
|
|||
return unresolvedCandidate("external", [
|
||||
"The bare specifier is a Node builtin or invalid package name.",
|
||||
]);
|
||||
if (input.moduleKind === "import" && request.packageName.includes("%"))
|
||||
if (
|
||||
input.moduleKind === "import" &&
|
||||
(request.packageName.startsWith(".") || request.packageName.includes("%"))
|
||||
)
|
||||
return unresolvedCandidate("rejected", [
|
||||
"Percent signs are not admitted in ES module package names.",
|
||||
"ES module package names cannot start with a dot or contain percent signs.",
|
||||
]);
|
||||
const self = packageSelfCandidate(input, request);
|
||||
if (self !== null) return self;
|
||||
|
|
@ -735,7 +745,7 @@ const htmlCandidate = (
|
|||
const exactUrlPathCandidate = (
|
||||
declared: string,
|
||||
candidate: string,
|
||||
kind: "HTML" | "URL",
|
||||
kind: "HTML" | "URL" | "module URL",
|
||||
): string | CandidateResolution => {
|
||||
if (!urlPathIsDirectory(declared)) return candidate;
|
||||
const confined = confineCandidate(candidate);
|
||||
|
|
|
|||
|
|
@ -81,8 +81,13 @@ export const addJavaScriptSourceModules = (
|
|||
if (asset === undefined || semantic === null || program === undefined)
|
||||
continue;
|
||||
const exports = semantic.ir.moduleLinks
|
||||
.flatMap(({ exportedName }) =>
|
||||
exportedName === null ? [] : [exportedName],
|
||||
.flatMap(({ exportedName, kind }) =>
|
||||
exportedName === null || kind === "type-import" ? [] : [exportedName],
|
||||
)
|
||||
.sort(compareUnicodeCodePoints);
|
||||
const typeExports = semantic.ir.moduleLinks
|
||||
.flatMap(({ exportedName, kind }) =>
|
||||
exportedName === null || kind !== "type-import" ? [] : [exportedName],
|
||||
)
|
||||
.sort(compareUnicodeCodePoints);
|
||||
const retainedExports = [...new Set(exports)];
|
||||
|
|
@ -105,8 +110,12 @@ export const addJavaScriptSourceModules = (
|
|||
import_relationships: semantic.ir.moduleLinks.filter(
|
||||
({ kind }) => kind === "import" || kind === "require",
|
||||
).length,
|
||||
type_relationships: semantic.ir.moduleLinks.filter(
|
||||
({ kind }) => kind === "type-import",
|
||||
).length,
|
||||
export_relationships: exports.length,
|
||||
export_names: retainedExports,
|
||||
type_export_names: [...new Set(typeExports)],
|
||||
omitted_export_names: 0,
|
||||
},
|
||||
evidence: astObservationEvidence({
|
||||
|
|
@ -152,6 +161,7 @@ export function* addJavaScriptModuleRelationshipsSteps(
|
|||
for (const link of semantic.ir.moduleLinks) {
|
||||
// One module can export thousands of bindings, each with return shapes.
|
||||
yield;
|
||||
if (link.kind === "type-import") continue;
|
||||
const input = { context, file, semantic, source, link };
|
||||
if (isExportLink(link)) addExportRelationship(input, omissions);
|
||||
else if (link.specifier !== null)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import {
|
||||
inspectSignalDecodersInputSchema,
|
||||
signalDecodersSchema,
|
||||
type InspectSignalDecodersInput,
|
||||
} from "../../domain/signals/signalDecoders.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
|
|
@ -24,6 +30,10 @@ export interface SignalRecordingPort {
|
|||
input: DecodeSignalRecordingInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
inspectDecoders(
|
||||
input: InspectSignalDecodersInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -74,14 +84,58 @@ export class SignalRecordingService {
|
|||
"Decoder returned malformed data or changed the selected recording identity/options.",
|
||||
),
|
||||
);
|
||||
const evidence = createEvidence(execution.subject, execution.provider, {
|
||||
operation,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
});
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
async inspectDecoders(
|
||||
raw: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const operation = "inspect_signal_decoders";
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const input = inspectSignalDecodersInputSchema.safeParse(raw);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(operation, input.error.issues, raw, {
|
||||
cause: input.error,
|
||||
}),
|
||||
);
|
||||
const decoded = await this.provider.inspectDecoders(input.data, options);
|
||||
if (!decoded.ok) return decoded;
|
||||
const execution = decoded.value;
|
||||
const report = signalDecodersSchema.safeParse(execution.result);
|
||||
if (
|
||||
!report.success ||
|
||||
JSON.stringify(report.data.selection) !== JSON.stringify(input.data) ||
|
||||
execution.subject?.path !== report.data.tool.path ||
|
||||
execution.subject.sha256 !== report.data.tool.sha256
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Decoder discovery returned malformed data or changed the selected installation/options.",
|
||||
),
|
||||
);
|
||||
return this.#evidence(operation, input.data, execution, options);
|
||||
}
|
||||
#evidence(
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
execution: AnalysisExecution,
|
||||
options?: ExecutionOptions,
|
||||
): Result<Evidence, AnalysisError> {
|
||||
const evidence = createEvidence(
|
||||
execution.subject ?? undefined,
|
||||
execution.provider,
|
||||
{
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
},
|
||||
);
|
||||
return options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(operation, {
|
||||
|
|
|
|||
|
|
@ -23,10 +23,11 @@ export const abortIfNeeded = (signal?: AbortSignal): void => {
|
|||
);
|
||||
};
|
||||
|
||||
/** Hash every stream byte and retain its bounded classification prefix. */
|
||||
/** Hash every stream byte, optionally feeding the same bytes to a scan aggregate. */
|
||||
export const hashReadable = async (
|
||||
stream: Readable,
|
||||
signal?: AbortSignal,
|
||||
observeChunk?: (chunk: Buffer) => void,
|
||||
): Promise<HashResult> => {
|
||||
const hash = createHash("sha256");
|
||||
const prefixes: Buffer[] = [];
|
||||
|
|
@ -37,6 +38,7 @@ export const hashReadable = async (
|
|||
const chunk = streamChunkToBuffer(raw);
|
||||
bytes += chunk.length;
|
||||
hash.update(chunk);
|
||||
observeChunk?.(chunk);
|
||||
if (prefixBytes < ARTIFACT_CLASSIFICATION_PREFIX_BYTES) {
|
||||
const selected = chunk.subarray(
|
||||
0,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,6 @@ class ArtifactClient implements AnalysisClient {
|
|||
const result = await extractArtifact(
|
||||
{
|
||||
inputPath: this.target.sourcePath ?? this.target.path,
|
||||
inputFormat: this.target.format,
|
||||
outputRoot: parsed.output_root,
|
||||
environment: this.environment,
|
||||
integrityPolicy: parsed.integrity_policy,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { readFileHandlePrefix } from "../filesystem/readFileHandlePrefix.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
parseInesLayout,
|
||||
|
|
@ -27,6 +26,9 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
|
|||
export const INES_LIMITATION =
|
||||
"Classic iNES file regions are observed; mapper execution, CPU bank mappings, CHR-RAM contents, 6502 analysis and emulator behavior are not observed. Trailing bytes are retained without interpretation. NES 2.0 and UNIF are unsupported.";
|
||||
|
||||
/** Actual concatenated bytes observed by the inventory's ordered region scan. */
|
||||
export type InesRegionScan = Omit<InesInventory["reconstruction"], "verified">;
|
||||
|
||||
/** Read positional regions from a descriptor retained by the artifact resource scope. */
|
||||
export class InesArtifactReader implements ArtifactReader {
|
||||
readonly format = "ines" as const;
|
||||
|
|
@ -120,35 +122,25 @@ export class InesArtifactReader implements ArtifactReader {
|
|||
);
|
||||
}
|
||||
|
||||
/** Hash actual ordered region bytes; per-region digests alone cannot prove concatenation. */
|
||||
/** Validate the completed region scan without rereading its payload. */
|
||||
async verifyReconstruction(
|
||||
expectedSha256: string,
|
||||
observed: InesRegionScan,
|
||||
signal?: AbortSignal,
|
||||
): Promise<InesInventory> {
|
||||
abortIfNeeded(signal);
|
||||
const layout = this.#layout;
|
||||
if (layout === undefined || this.#entries.size !== layout.regions.length)
|
||||
throw new ArtifactReaderFailure("integrity", "Incomplete iNES inventory");
|
||||
const hash = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for (const region of layout.regions) {
|
||||
if (region.offset !== bytes)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Noncontiguous iNES regions",
|
||||
);
|
||||
const entry = this.#entries.get(region.path);
|
||||
if (entry === undefined)
|
||||
throw new ArtifactReaderFailure("integrity", "Missing iNES region");
|
||||
for await (const raw of await this.open(entry, signal)) {
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
hash.update(chunk);
|
||||
bytes += chunk.length;
|
||||
}
|
||||
}
|
||||
abortIfNeeded(signal);
|
||||
const sha256 = hash.digest("hex");
|
||||
if (bytes !== this.source.initial.size || sha256 !== expectedSha256)
|
||||
await this.#verifySource(signal);
|
||||
if (
|
||||
observed.region_order.length !== layout.regions.length ||
|
||||
layout.regions.some(
|
||||
(region, index) => observed.region_order[index] !== region.path,
|
||||
) ||
|
||||
observed.bytes !== this.source.initial.size ||
|
||||
observed.sha256 !== expectedSha256
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"iNES reconstruction differs from the source",
|
||||
|
|
@ -156,9 +148,7 @@ export class InesArtifactReader implements ArtifactReader {
|
|||
return {
|
||||
header: layout.header,
|
||||
reconstruction: {
|
||||
region_order: layout.regions.map(({ path }) => path),
|
||||
bytes,
|
||||
sha256,
|
||||
...observed,
|
||||
verified: true,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
if (!captured.ok)
|
||||
throw new ArtifactReaderFailure(
|
||||
captured.error.reason === "cancelled" ? "cancelled" : "unavailable",
|
||||
"lipo could not enumerate universal Mach-O slices",
|
||||
captured.error.detail === null
|
||||
? "lipo could not enumerate universal Mach-O slices"
|
||||
: `lipo could not enumerate universal Mach-O slices: ${captured.error.detail}`,
|
||||
{
|
||||
cause: captured.error,
|
||||
...(captured.error.cleanupFailure === null &&
|
||||
|
|
|
|||
|
|
@ -280,9 +280,11 @@ export class SafeOutputTree {
|
|||
}
|
||||
|
||||
/** Sync the owned output tree and prevent further writes through this instance. */
|
||||
async commit(): Promise<void> {
|
||||
async commit(signal?: AbortSignal): Promise<void> {
|
||||
this.#assertWritable();
|
||||
abortIfNeeded(signal);
|
||||
await this.#assertOwnedDirectories();
|
||||
abortIfNeeded(signal);
|
||||
// Windows has no directory fsync; file contents are already synced in write().
|
||||
if (this.platform === "win32") {
|
||||
this.#published = true;
|
||||
|
|
@ -315,6 +317,7 @@ export class SafeOutputTree {
|
|||
);
|
||||
await output.handle.sync();
|
||||
await parent.handle.sync();
|
||||
abortIfNeeded(signal);
|
||||
this.#published = true;
|
||||
} catch (cause: unknown) {
|
||||
sync = { kind: "failed", cause };
|
||||
|
|
@ -336,23 +339,26 @@ export class SafeOutputTree {
|
|||
closeFailure,
|
||||
this.#outputRoot,
|
||||
);
|
||||
if (sync.kind === "failed")
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Could not durably sync extraction output",
|
||||
{ cause: sync.cause },
|
||||
),
|
||||
cleanup,
|
||||
);
|
||||
if (sync.kind === "failed") {
|
||||
const primary = isCancellationFailure(sync.cause)
|
||||
? sync.cause
|
||||
: new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Could not durably sync extraction output",
|
||||
{ cause: sync.cause },
|
||||
);
|
||||
throw ArtifactReaderFailure.withCleanup(primary, cleanup);
|
||||
}
|
||||
throw ArtifactReaderFailure.withCleanup(closeFailure, cleanup);
|
||||
}
|
||||
if (sync.kind === "failed")
|
||||
if (sync.kind === "failed") {
|
||||
if (isCancellationFailure(sync.cause)) throw sync.cause;
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"Could not durably sync extraction output",
|
||||
{ cause: sync.cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove only this operation's unsealed tree and verify absence. */
|
||||
|
|
@ -590,6 +596,11 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
|
|||
);
|
||||
};
|
||||
|
||||
const isCancellationFailure = (
|
||||
cause: unknown,
|
||||
): cause is ArtifactReaderFailure =>
|
||||
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled";
|
||||
|
||||
const isAlreadyExists = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { realpath } from "node:fs/promises";
|
||||
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||
|
|
@ -37,10 +37,7 @@ import type {
|
|||
ArtifactResourceOwner,
|
||||
ArtifactResourceScope,
|
||||
} from "../ArtifactResourceScope.js";
|
||||
import {
|
||||
isMachOFormat,
|
||||
type BinaryTarget,
|
||||
} from "../../domain/binaryTargetTypes.js";
|
||||
import { isMachOFormat } from "../../domain/binaryTargetTypes.js";
|
||||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import { scanArtifactInventoryInScope } from "../inventory/ArtifactInventory.js";
|
||||
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
|
||||
|
|
@ -48,7 +45,6 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
|
|||
/** Local extraction input with the output root chosen by the adapter. */
|
||||
export interface ArtifactExtractionInput {
|
||||
readonly inputPath: string;
|
||||
readonly inputFormat: BinaryTarget["format"];
|
||||
readonly outputRoot: string;
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly integrityPolicy: ArtifactIntegrityPolicyName;
|
||||
|
|
@ -69,12 +65,13 @@ const extractArtifactInScope = async (
|
|||
// Cancellation wins over every later refusal, including unsupported formats.
|
||||
abortIfNeeded(signal);
|
||||
const sourcePath = await realpath(input.inputPath);
|
||||
await requireExtractableFormat(sourcePath, input);
|
||||
const snapshot = await scanArtifactInventoryInScope(sourcePath, {
|
||||
resourceScope: input.resourceScope,
|
||||
signal,
|
||||
environment: input.environment,
|
||||
integrity: { mode: input.integrityPolicy },
|
||||
admitRootFormat: (format) =>
|
||||
requireExtractableFormat(format, input.inputPath),
|
||||
});
|
||||
return materializeArtifactInventoryInScope(
|
||||
input,
|
||||
|
|
@ -259,7 +256,7 @@ const materializeSelection = async ({
|
|||
compareUnicodeCodePoints(left.relative_path, right.relative_path),
|
||||
);
|
||||
completed = createExtractionResult(input, inventory, selected, extracted);
|
||||
await output.commit();
|
||||
await output.commit(signal);
|
||||
return completed;
|
||||
} catch (cause: unknown) {
|
||||
const cleanupFailures = await releaseExtractionOwners(
|
||||
|
|
@ -412,18 +409,14 @@ interface ExtractionInventory {
|
|||
}
|
||||
|
||||
/**
|
||||
* Refuse a format without an extraction reader before inventory work starts.
|
||||
* The target kind, not the host, is unsupported by extraction. The error keeps
|
||||
* the caller's spelling of the path; I/O uses the canonical path.
|
||||
* Admit the scanner's format, preserving the caller's path in a refusal.
|
||||
*/
|
||||
const requireExtractableFormat = async (
|
||||
path: string,
|
||||
input: Pick<ArtifactExtractionInput, "inputPath" | "inputFormat">,
|
||||
): Promise<boolean> => {
|
||||
const format = input.inputFormat;
|
||||
const directory = (await lstat(path)).isDirectory();
|
||||
const requireExtractableFormat = (
|
||||
format: ArtifactGraphManifest["root_format"],
|
||||
inputPath: string,
|
||||
): void => {
|
||||
if (
|
||||
!directory &&
|
||||
format !== "directory" &&
|
||||
format !== "asar" &&
|
||||
format !== "ines" &&
|
||||
!isMachOFormat(format) &&
|
||||
|
|
@ -431,10 +424,9 @@ const requireExtractableFormat = async (
|
|||
)
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
"extract_artifact",
|
||||
input.inputPath,
|
||||
inputPath,
|
||||
`Artifact format has no extraction reader: ${format}`,
|
||||
);
|
||||
return directory;
|
||||
};
|
||||
|
||||
const createReader = async (
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ export const classifyAndHashRoot = async (
|
|||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "admitRootFormat"
|
||||
>,
|
||||
): Promise<RootClassification> => {
|
||||
return options.resourceScope.run(async () => {
|
||||
const result = await classifyAndHashRootForInventory(
|
||||
|
|
@ -88,10 +91,16 @@ export const classifyAndHashRootForInventory = async (
|
|||
path: string,
|
||||
directory: boolean,
|
||||
expectedMetadata: Stats,
|
||||
options: Pick<ArtifactInventoryOptions, "resourceScope" | "signal">,
|
||||
options: Pick<
|
||||
ArtifactInventoryOptions,
|
||||
"resourceScope" | "signal" | "admitRootFormat"
|
||||
>,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
abortIfNeeded(options.signal);
|
||||
if (directory) {
|
||||
options.admitRootFormat?.("directory");
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
}
|
||||
const ownedHandle = await openRootArtifact(
|
||||
path,
|
||||
options.resourceScope,
|
||||
|
|
@ -123,6 +132,8 @@ export const classifyAndHashRootForInventory = async (
|
|||
initial,
|
||||
options.signal,
|
||||
);
|
||||
abortIfNeeded(options.signal);
|
||||
options.admitRootFormat?.(format);
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
handle,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js";
|
||||
import type { InesInventory } from "../../domain/inesContainer.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import { InesArtifactReader } from "../InesArtifactReader.js";
|
||||
import {
|
||||
InesArtifactReader,
|
||||
type InesRegionScan,
|
||||
} from "../InesArtifactReader.js";
|
||||
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
|
|
@ -100,6 +104,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
|||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
} = await scanReader(reader, ownedReaders, options.signal, integrity);
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
|
|
@ -116,6 +121,7 @@ export const scanCanonicalArtifactInventoryInScope = async (
|
|||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation,
|
||||
inesRegionScan,
|
||||
}),
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
|
|
@ -197,6 +203,7 @@ interface SnapshotBuildInput {
|
|||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
}
|
||||
|
||||
const buildInventorySnapshot = async (
|
||||
|
|
@ -275,11 +282,22 @@ const buildInventorySnapshot = async (
|
|||
integrityContradictions,
|
||||
input.caseCollisionLimitation,
|
||||
);
|
||||
let ines: InesInventory | undefined;
|
||||
if (reader instanceof InesArtifactReader) {
|
||||
if (rootDigest === null || input.inesRegionScan === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Missing iNES reconstruction scan",
|
||||
);
|
||||
ines = await reader.verifyReconstruction(
|
||||
rootDigest.sha256,
|
||||
input.inesRegionScan,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
return {
|
||||
manifest,
|
||||
...(reader instanceof InesArtifactReader && rootDigest !== null
|
||||
? { ines: await reader.verifyReconstruction(rootDigest.sha256, signal) }
|
||||
: {}),
|
||||
...(ines === undefined ? {} : { ines }),
|
||||
nodes: orderedNodes,
|
||||
occurrences: orderedOccurrences,
|
||||
edges: orderedEdges,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { isAbsolute } from "node:path";
|
||||
import { createHash, type Hash } from "node:crypto";
|
||||
|
||||
import {
|
||||
annotateEnglishUnicodeCaseCollisions,
|
||||
|
|
@ -11,6 +12,10 @@ import {
|
|||
type ArtifactReader,
|
||||
} from "../ArtifactReader.js";
|
||||
import { AsarArtifactReader } from "../AsarArtifactReader.js";
|
||||
import {
|
||||
InesArtifactReader,
|
||||
type InesRegionScan,
|
||||
} from "../InesArtifactReader.js";
|
||||
import type { ArtifactNode } from "../../domain/artifactGraph.js";
|
||||
import {
|
||||
classifyArtifactContent,
|
||||
|
|
@ -55,6 +60,13 @@ interface ScanContext {
|
|||
readonly registry: ArtifactPathRegistry;
|
||||
readonly expandedContainerIds: Set<string>;
|
||||
readonly ownedReaders: ArtifactReader[];
|
||||
readonly reconstruction:
|
||||
| {
|
||||
readonly hash: Hash;
|
||||
readonly regionOrder: string[];
|
||||
bytes: number;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const scanReader = async (
|
||||
|
|
@ -67,6 +79,7 @@ export const scanReader = async (
|
|||
readonly occurrences: MutableOccurrence[];
|
||||
readonly pendingContradictions: PendingIntegrityContradiction[];
|
||||
readonly caseCollisionLimitation: string | undefined;
|
||||
readonly inesRegionScan: InesRegionScan | undefined;
|
||||
}> => {
|
||||
const { nodes, occurrences, pendingContradictions } = emptyScan();
|
||||
if (reader === undefined)
|
||||
|
|
@ -75,6 +88,7 @@ export const scanReader = async (
|
|||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: undefined,
|
||||
inesRegionScan: undefined,
|
||||
};
|
||||
const context: ScanContext = {
|
||||
reader,
|
||||
|
|
@ -87,6 +101,10 @@ export const scanReader = async (
|
|||
registry: new ArtifactPathRegistry(),
|
||||
expandedContainerIds: new Set<string>(),
|
||||
ownedReaders,
|
||||
reconstruction:
|
||||
reader instanceof InesArtifactReader
|
||||
? { hash: createHash("sha256"), regionOrder: [], bytes: 0 }
|
||||
: undefined,
|
||||
};
|
||||
await visitArtifactEntries(context, reader, "");
|
||||
// Archive directories may appear after their children. Resolve containment
|
||||
|
|
@ -101,6 +119,14 @@ export const scanReader = async (
|
|||
occurrences,
|
||||
pendingContradictions,
|
||||
caseCollisionLimitation: annotateEnglishUnicodeCaseCollisions(occurrences),
|
||||
inesRegionScan:
|
||||
context.reconstruction === undefined
|
||||
? undefined
|
||||
: {
|
||||
region_order: context.reconstruction.regionOrder,
|
||||
bytes: context.reconstruction.bytes,
|
||||
sha256: context.reconstruction.hash.digest("hex"),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -233,10 +259,22 @@ const digestArtifactEntry = async (
|
|||
> => {
|
||||
if ((entry.kind !== "file" && entry.kind !== "slice") || entry.encrypted)
|
||||
return undefined;
|
||||
const reconstruction =
|
||||
currentReader === context.reader ? context.reconstruction : undefined;
|
||||
if (reconstruction !== undefined && entry.byteOffset !== reconstruction.bytes)
|
||||
throw new ArtifactReaderFailure("integrity", "Noncontiguous iNES regions");
|
||||
const digest = await hashReadable(
|
||||
await currentReader.open(entry, context.signal),
|
||||
context.signal,
|
||||
reconstruction === undefined
|
||||
? undefined
|
||||
: (chunk) => {
|
||||
// Hash the same bytes used for each node, in physical region order.
|
||||
reconstruction.hash.update(chunk);
|
||||
reconstruction.bytes += chunk.length;
|
||||
},
|
||||
);
|
||||
reconstruction?.regionOrder.push(entry.path);
|
||||
const mismatched =
|
||||
entry.declaredSha256 !== null && entry.declaredSha256 !== digest.sha256;
|
||||
if (mismatched && entry.declaredSha256 !== null) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
||||
|
||||
/** Resolved integrity behavior admitted to the artifact scanner. */
|
||||
export type ArtifactIntegrityPolicy =
|
||||
|
|
@ -15,4 +16,8 @@ export interface ArtifactInventoryOptions {
|
|||
readonly signal?: AbortSignal | undefined;
|
||||
readonly integrity?: ArtifactIntegrityPolicy | undefined;
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv> | undefined;
|
||||
/** Admit the current root before payload hashing or native child inventory. */
|
||||
readonly admitRootFormat?: (
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ export const browserScenarioCaptureData = (input: {
|
|||
network_content: input.scenario.capture.network,
|
||||
event_families: [...new Set(input.scenario.capture.events)].sort(),
|
||||
},
|
||||
duration_ms: Date.now() - input.startedAt,
|
||||
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
|
||||
steps: [...input.steps],
|
||||
events,
|
||||
limitations: [
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ describe("PlaywrightBrowserScenarioProvider", () => {
|
|||
});
|
||||
expect(result.value.limitations).toContain(session.eventGaps[0]);
|
||||
});
|
||||
|
||||
it("returns an initial state and every step beyond the former action ceiling", async () => {
|
||||
const session = new FakeSession("launch");
|
||||
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
||||
|
|
@ -376,6 +377,35 @@ describe("PlaywrightBrowserScenarioProvider", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("PlaywrightBrowserScenarioProvider timing", () => {
|
||||
it("keeps capture elapsed values valid across a backward wall-clock jump", async () => {
|
||||
let wallClock = 10_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => wallClock);
|
||||
const session = new FakeSession("launch");
|
||||
session.onPerform = () => {
|
||||
wallClock = 1;
|
||||
};
|
||||
const provider = new PlaywrightBrowserScenarioProvider(() =>
|
||||
Promise.resolve(session),
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await provider.captureScenario(scenario());
|
||||
if (!result.ok) throw result.error;
|
||||
expect(wallClock).toBe(1);
|
||||
expect(Number.isInteger(result.value.duration_ms)).toBe(true);
|
||||
expect(result.value.duration_ms).toBeGreaterThanOrEqual(0);
|
||||
expect(
|
||||
result.value.steps.every(
|
||||
({ elapsed_ms }) => Number.isInteger(elapsed_ms) && elapsed_ms >= 0,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlaywrightBrowserScenarioProvider comparison coverage", () => {
|
||||
it("keeps quiet captures with different event families from comparing unchanged", async () => {
|
||||
const capture = async (events: readonly BrowserScenarioEventFamily[]) => {
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ const executeStep = async (input: {
|
|||
),
|
||||
});
|
||||
|
||||
const actionStartedAt = Date.now();
|
||||
const actionStartedAt = performance.now();
|
||||
session.setStep(stepIndex);
|
||||
const requestCancelled = (): boolean => signal?.aborted === true;
|
||||
let outcome: BrowserScenarioStepOutcome = {
|
||||
|
|
@ -200,7 +200,7 @@ const executeStep = async (input: {
|
|||
stepId: action.step_id,
|
||||
action: action.action,
|
||||
...outcome,
|
||||
elapsedMs: Date.now() - actionStartedAt,
|
||||
elapsedMs: Math.max(0, Math.round(performance.now() - actionStartedAt)),
|
||||
beforeUrl,
|
||||
afterUrl: session.sanitizeUrl(session.currentUrl()),
|
||||
eventStart,
|
||||
|
|
@ -233,7 +233,7 @@ const runScenario = async (
|
|||
): Promise<BrowserScenarioCapture> => {
|
||||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
const startedAt = Date.now();
|
||||
const startedAt = performance.now();
|
||||
const session = await openSession(scenario, {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
retainCleanup,
|
||||
|
|
@ -250,7 +250,7 @@ const runScenario = async (
|
|||
await initialStep({
|
||||
session,
|
||||
scenario,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
||||
signal: options.signal,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export const runElectronActions = async (
|
|||
): Promise<ElectronActions> => {
|
||||
const actions: ElectronActions = [];
|
||||
for (const action of input.actions) {
|
||||
const actionStartedAt = Date.now();
|
||||
const actionStartedAt = performance.now();
|
||||
const windowIndex = actionWindowIndex(action);
|
||||
let selectedWindow: Page | undefined;
|
||||
try {
|
||||
|
|
@ -107,7 +107,10 @@ export const runElectronActions = async (
|
|||
target:
|
||||
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
|
||||
status: "completed",
|
||||
elapsed_ms: Date.now() - actionStartedAt,
|
||||
elapsed_ms: Math.max(
|
||||
0,
|
||||
Math.round(performance.now() - actionStartedAt),
|
||||
),
|
||||
error: null,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
|
|
@ -118,7 +121,10 @@ export const runElectronActions = async (
|
|||
target:
|
||||
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
|
||||
status: options.signal?.aborted ? "cancelled" : "failed",
|
||||
elapsed_ms: Date.now() - actionStartedAt,
|
||||
elapsed_ms: Math.max(
|
||||
0,
|
||||
Math.round(performance.now() - actionStartedAt),
|
||||
),
|
||||
error: safeActionErrorMessage(cause),
|
||||
});
|
||||
break;
|
||||
|
|
@ -366,7 +372,7 @@ export const runWithExecutionLimits = async <Value>(
|
|||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new BrowserObservationError(OPERATION, "timeout")),
|
||||
Math.max(1, deadline - Date.now()),
|
||||
Math.max(1, deadline - performance.now()),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
if (options.signal?.aborted === true)
|
||||
throw new BrowserObservationError(OPERATION, "cancelled");
|
||||
await prepareProcessOwnershipInspection(options.signal);
|
||||
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
const startupDeadline = performance.now() + STARTUP_TIMEOUT_MS;
|
||||
paths = {
|
||||
executable: input.executable_path,
|
||||
application: input.application_path,
|
||||
|
|
@ -101,7 +101,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
cwd: paths.root,
|
||||
env: safeElectronEnvironment(this.environment, runId),
|
||||
args: ["-r", hookPath, paths.application, ...input.args],
|
||||
timeout: Math.max(1, startupDeadline - Date.now()),
|
||||
timeout: Math.max(1, startupDeadline - performance.now()),
|
||||
});
|
||||
const leaderPid = application.process().pid;
|
||||
if (
|
||||
|
|
@ -128,7 +128,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
const state = await runWithExecutionLimits(
|
||||
readApplicationState(application),
|
||||
undefined,
|
||||
options.signal?.aborted ? Date.now() + 5_000 : undefined,
|
||||
options.signal?.aborted ? performance.now() + 5_000 : undefined,
|
||||
);
|
||||
capture = projectElectronActiveCapture(paths, actions, state);
|
||||
outcome = options.signal?.aborted
|
||||
|
|
@ -187,7 +187,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
|
|||
await runWithExecutionLimits(
|
||||
application.close(),
|
||||
undefined,
|
||||
Date.now() + 5_000,
|
||||
performance.now() + 5_000,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
closeError = cause;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import {
|
|||
} from "../domain/analysisErrorCore.js";
|
||||
|
||||
describe("withPlaywrightExecutionBoundary", () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects work that exceeds its absolute remaining duration", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -31,4 +34,20 @@ describe("withPlaywrightExecutionBoundary", () => {
|
|||
controller.abort();
|
||||
await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError);
|
||||
});
|
||||
|
||||
it("keeps its timeout stable across a wall-clock jump", async () => {
|
||||
vi.spyOn(Date, "now")
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValue(10_000_000);
|
||||
|
||||
await expect(
|
||||
withPlaywrightExecutionBoundary(
|
||||
() =>
|
||||
new Promise<string>((resolve) =>
|
||||
setTimeout(() => resolve("done"), 5),
|
||||
),
|
||||
1_000,
|
||||
),
|
||||
).resolves.toBe("done");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ export const withPlaywrightExecutionBoundary = <Value>(
|
|||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline =
|
||||
timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
|
||||
timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
|
||||
const scheduleTimeout = (): void => {
|
||||
if (deadline === undefined) return;
|
||||
const remaining = deadline - Date.now();
|
||||
const remaining = deadline - performance.now();
|
||||
if (remaining <= 0) {
|
||||
fail(new AnalysisTimeoutError(OPERATION, timeoutMs ?? 0));
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
|
||||
if (secrets === undefined)
|
||||
throw new BrowserObservationError(OPERATION, "secret_unavailable");
|
||||
const startedAt = Date.now();
|
||||
const startedAt = performance.now();
|
||||
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
|
||||
signal: options.signal,
|
||||
...(options.retainCleanup === undefined
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const fetchWebSourceMaps = async (
|
|||
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
||||
const operationSignal = operationController.signal;
|
||||
const deadlineAt =
|
||||
Date.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
|
||||
performance.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
|
||||
const budget = {
|
||||
retainedBytes: 0,
|
||||
deadlineAt,
|
||||
|
|
@ -110,7 +110,7 @@ export const fetchWebSourceMaps = async (
|
|||
}
|
||||
const expandedBytesBeforeItem = budget.decodedRecords.expandedBytes;
|
||||
budget.decodedRecords.expandedBytes += reservation;
|
||||
if (operationSignal.aborted || Date.now() >= deadlineAt) {
|
||||
if (operationSignal.aborted || performance.now() >= deadlineAt) {
|
||||
items.push(
|
||||
emptySourceMapItem(
|
||||
request,
|
||||
|
|
@ -789,7 +789,7 @@ const checkOperation = (
|
|||
): void => {
|
||||
if (callerSignal?.aborted === true) throw callerSignal.reason;
|
||||
if (operationSignal?.aborted === true) throw operationSignal.reason;
|
||||
if (Date.now() >= deadlineAt) throw new SourceMapDeadlineError();
|
||||
if (performance.now() >= deadlineAt) throw new SourceMapDeadlineError();
|
||||
};
|
||||
|
||||
const approvedUrl = (
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export const publishWebScripts = async (
|
|||
options.signal,
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await tree.commit();
|
||||
await tree.commit(options.signal);
|
||||
return result;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof SafeOutputTreeCreationFailure) tree = cause.tree;
|
||||
|
|
|
|||
|
|
@ -144,7 +144,8 @@ const projectHar = (
|
|||
(literal) =>
|
||||
item.includes(literal) ||
|
||||
contentBase64.includes(literal) ||
|
||||
sha256.includes(literal),
|
||||
sha256.includes(literal) ||
|
||||
String(bytes.length).includes(literal),
|
||||
);
|
||||
binary_fields.push(
|
||||
hidden
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
import {
|
||||
type InspectPacketFieldsInput,
|
||||
packetFieldsCatalogSchema,
|
||||
} from "../domain/captures/packetFields.js";
|
||||
import {
|
||||
parseFieldDiscovery,
|
||||
FieldDiscoveryBudgetError,
|
||||
FIELD_RECORD_BUDGET,
|
||||
} from "./parseFieldDiscovery.js";
|
||||
import {
|
||||
readLinuxResourceLimits,
|
||||
type LinuxResourceLimits,
|
||||
|
|
@ -91,17 +100,31 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#resources.run(
|
||||
() => this.#inspect(input, options),
|
||||
() => this.#executeRequest(input, options),
|
||||
options?.signal,
|
||||
);
|
||||
}
|
||||
inspectFields(
|
||||
input: InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
return this.#resources.run(
|
||||
() => this.#executeRequest(input, options),
|
||||
options?.signal,
|
||||
"inspect_packet_fields",
|
||||
);
|
||||
}
|
||||
close(): Promise<void> {
|
||||
return this.#resources.close();
|
||||
}
|
||||
async #inspect(
|
||||
input: InspectPacketCaptureInput,
|
||||
async #executeRequest(
|
||||
input: InspectPacketCaptureInput | InspectPacketFieldsInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
const operation = "path" in input ? OPERATION : "inspect_packet_fields";
|
||||
// TShark 4.6.9 emits a 22.3 MB glossary; keep capture output capped at 4 MiB.
|
||||
const limits =
|
||||
"path" in input ? LIMITS : { ...LIMITS, output_bytes: 32 * 1024 * 1024 };
|
||||
let phase:
|
||||
| "configuration"
|
||||
| "artifact"
|
||||
|
|
@ -109,12 +132,12 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
| "producer"
|
||||
| "integrity" = "configuration";
|
||||
let capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
let resourcePath = input.path;
|
||||
let resourcePath = "path" in input ? input.path : "TShark field glossary";
|
||||
let effectiveLimits: LinuxResourceLimits | undefined;
|
||||
let root: ArtifactAnalysisRuntime | undefined;
|
||||
let result: Result<AnalysisExecution, AnalysisError>;
|
||||
try {
|
||||
if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
|
||||
if (options?.signal?.aborted) throw new AnalysisCancelledError(operation);
|
||||
await this.#resources.release();
|
||||
if (this.platform !== "linux")
|
||||
throw new Error(
|
||||
|
|
@ -141,7 +164,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
64 * 1024 * 1024,
|
||||
options?.signal,
|
||||
);
|
||||
effectiveLimits = await this.#resources.readLimits(LIMITS);
|
||||
effectiveLimits = await this.#resources.readLimits(limits);
|
||||
const limitArguments = [
|
||||
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
|
||||
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
|
||||
|
|
@ -152,7 +175,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
];
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined);
|
||||
this.#resources.retain(root, undefined, operation);
|
||||
const snapshotPath = join(root.path, "capture");
|
||||
const configurationPath = join(root.path, "configuration");
|
||||
await mkdir(configurationPath, { mode: 0o700 });
|
||||
|
|
@ -170,8 +193,8 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: LIMITS.command_timeout_ms,
|
||||
diagnosticBytes: LIMITS.output_bytes,
|
||||
timeoutMs: limits.command_timeout_ms,
|
||||
diagnosticBytes: limits.output_bytes,
|
||||
acceptNonZeroExit: true,
|
||||
},
|
||||
{
|
||||
|
|
@ -200,24 +223,24 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
);
|
||||
if (currentLimiter.sha256 !== limiterIdentity.sha256)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
limiter,
|
||||
"Configured resource limiter changed during analysis.",
|
||||
{ capturedOutput },
|
||||
);
|
||||
if (current.sha256 !== tool.sha256)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
command,
|
||||
"Configured TShark executable changed during analysis.",
|
||||
{ capturedOutput },
|
||||
);
|
||||
phase = previousPhase;
|
||||
resourcePath = input.path;
|
||||
resourcePath = "path" in input ? input.path : command;
|
||||
if (output.exitCode !== 0)
|
||||
throw new ProviderAdapterError(
|
||||
TSHARK_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
operation,
|
||||
{
|
||||
diagnostics: {
|
||||
exit_code: output.exitCode ?? null,
|
||||
|
|
@ -238,86 +261,15 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
throw new Error(
|
||||
"Expected TShark 4.x; inspect the selected installation.",
|
||||
);
|
||||
phase = "artifact";
|
||||
const selected = await this.#resources.read(
|
||||
input.path,
|
||||
LIMITS.input_bytes,
|
||||
options?.signal,
|
||||
);
|
||||
phase = "workspace";
|
||||
await writeFile(snapshotPath, selected.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
phase = "producer";
|
||||
const args = [
|
||||
"-n",
|
||||
"-r",
|
||||
"capture",
|
||||
"-T",
|
||||
"json",
|
||||
"--no-duplicate-keys",
|
||||
"--temp-dir=.",
|
||||
...(input.two_pass ? ["-2"] : []),
|
||||
...(input.packet_limit === null
|
||||
? []
|
||||
: ["-c", String(input.packet_limit)]),
|
||||
...(input.display_filter === null ? [] : ["-Y", input.display_filter]),
|
||||
...input.decode_as.flatMap((value) => ["-d", value]),
|
||||
...[...new Set(["frame.number", ...input.fields])].flatMap((field) => [
|
||||
"-e",
|
||||
field,
|
||||
]),
|
||||
];
|
||||
const output = await execute(args);
|
||||
phase = "integrity";
|
||||
resourcePath = snapshotPath;
|
||||
if (
|
||||
(await this.#resources.read(snapshotPath, LIMITS.input_bytes))
|
||||
.sha256 !== selected.sha256
|
||||
)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
snapshotPath,
|
||||
"TShark changed the private capture copy.",
|
||||
capturedOutput === undefined ? {} : { capturedOutput },
|
||||
);
|
||||
phase = "producer";
|
||||
resourcePath = input.path;
|
||||
const matchingPackets = parsePacketOutput(
|
||||
output.stdout.text,
|
||||
input.fields,
|
||||
);
|
||||
const start = Math.min(input.match_offset, matchingPackets.length);
|
||||
const end =
|
||||
input.match_limit === null
|
||||
? matchingPackets.length
|
||||
: start + Math.min(input.match_limit, matchingPackets.length - start);
|
||||
const packets = matchingPackets.slice(start, end);
|
||||
const report = packetCaptureSchema.parse({
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
bytes: selected.bytes.length,
|
||||
},
|
||||
selection: input,
|
||||
packets,
|
||||
output_window: {
|
||||
matching_packets: matchingPackets.length,
|
||||
returned_packets: packets.length,
|
||||
has_more: end < matchingPackets.length,
|
||||
},
|
||||
read_scope:
|
||||
input.packet_limit === null ? "to-eof" : "up-to-input-packet-limit",
|
||||
configuration: "private-empty-personal-profile",
|
||||
diagnostics: output.stderr.text,
|
||||
const metadata = (args: readonly string[], diagnostics: string) => ({
|
||||
diagnostics,
|
||||
tool: {
|
||||
configured_path: configured,
|
||||
path: command,
|
||||
sha256: tool.sha256,
|
||||
version_banner: version.stdout.text,
|
||||
version_diagnostics: version.stderr.text,
|
||||
arguments: args,
|
||||
arguments: [...args],
|
||||
},
|
||||
resource_limiter: {
|
||||
configured_path: configuredLimiter,
|
||||
|
|
@ -325,33 +277,136 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
sha256: limiterIdentity.sha256,
|
||||
arguments: limitArguments,
|
||||
},
|
||||
limits: { ...LIMITS, ...effectiveLimits },
|
||||
hardware_acquisition: "not-performed",
|
||||
limits: { ...limits, ...effectiveLimits },
|
||||
hardware_acquisition: "not-performed" as const,
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: {
|
||||
if (!("path" in input)) {
|
||||
phase = "producer";
|
||||
const args = ["-G", "fields"];
|
||||
const output = await execute(args);
|
||||
const report = packetFieldsCatalogSchema.parse({
|
||||
selection: input,
|
||||
...parseFieldDiscovery(output.stdout.text, input),
|
||||
...metadata(args, output.stderr.text),
|
||||
limits: {
|
||||
...limits,
|
||||
...effectiveLimits,
|
||||
record_budget_bytes: FIELD_RECORD_BUDGET,
|
||||
},
|
||||
configuration: "private-empty-personal-profile",
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: { path: command, sha256: tool.sha256, format: "file" },
|
||||
limitations: [
|
||||
"Protocol and field registrations describe the selected installation, not traffic observed in a capture. Duplicate abbreviations remain distinct producer records with source line numbers.",
|
||||
"Each request probes again without caching. The private empty personal profile does not fingerprint or isolate installed libraries, global configuration or plugins; they remain trusted.",
|
||||
"No capture file, interface or device is opened. Resource ceilings do not constitute an OS sandbox. Glossary output is capped at 32 MiB and selected record representation at 8 MiB before expansion.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
phase = "artifact";
|
||||
const selected = await this.#resources.read(
|
||||
input.path,
|
||||
LIMITS.input_bytes,
|
||||
options?.signal,
|
||||
);
|
||||
phase = "workspace";
|
||||
await writeFile(snapshotPath, selected.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o400,
|
||||
});
|
||||
phase = "producer";
|
||||
const args = [
|
||||
"-n",
|
||||
"-r",
|
||||
"capture",
|
||||
"-T",
|
||||
"json",
|
||||
"--no-duplicate-keys",
|
||||
"--temp-dir=.",
|
||||
...(input.two_pass ? ["-2"] : []),
|
||||
...(input.packet_limit === null
|
||||
? []
|
||||
: ["-c", String(input.packet_limit)]),
|
||||
...(input.display_filter === null
|
||||
? []
|
||||
: ["-Y", input.display_filter]),
|
||||
...input.decode_as.flatMap((value) => ["-d", value]),
|
||||
...[...new Set(["frame.number", ...input.fields])].flatMap(
|
||||
(field) => ["-e", field],
|
||||
),
|
||||
];
|
||||
const output = await execute(args);
|
||||
phase = "integrity";
|
||||
resourcePath = snapshotPath;
|
||||
if (
|
||||
(await this.#resources.read(snapshotPath, LIMITS.input_bytes))
|
||||
.sha256 !== selected.sha256
|
||||
)
|
||||
throw new AnalysisArtifactChangedError(
|
||||
operation,
|
||||
snapshotPath,
|
||||
"TShark changed the private capture copy.",
|
||||
capturedOutput === undefined ? {} : { capturedOutput },
|
||||
);
|
||||
phase = "producer";
|
||||
resourcePath = "path" in input ? input.path : command;
|
||||
const matchingPackets = parsePacketOutput(
|
||||
output.stdout.text,
|
||||
input.fields,
|
||||
);
|
||||
const start = Math.min(input.match_offset, matchingPackets.length);
|
||||
const end =
|
||||
input.match_limit === null
|
||||
? matchingPackets.length
|
||||
: start +
|
||||
Math.min(input.match_limit, matchingPackets.length - start);
|
||||
const packets = matchingPackets.slice(start, end);
|
||||
const report = packetCaptureSchema.parse({
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
format: "file",
|
||||
bytes: selected.bytes.length,
|
||||
},
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
limitations: [
|
||||
"Fields are TShark dissector observations under the selected filter/decode-as/pass settings, not independently verified protocol semantics. Missing fields are absent from the producer output, not known zero or empty values.",
|
||||
"Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.",
|
||||
"Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.",
|
||||
"A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.",
|
||||
"Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
selection: input,
|
||||
packets,
|
||||
output_window: {
|
||||
matching_packets: matchingPackets.length,
|
||||
returned_packets: packets.length,
|
||||
has_more: end < matchingPackets.length,
|
||||
},
|
||||
read_scope:
|
||||
input.packet_limit === null ? "to-eof" : "up-to-input-packet-limit",
|
||||
configuration: "private-empty-personal-profile",
|
||||
...metadata(args, output.stderr.text),
|
||||
});
|
||||
result = ok(
|
||||
createAnalysisExecution(report, TSHARK_PROVIDER_IDENTITY, {
|
||||
subject: {
|
||||
path: input.path,
|
||||
sha256: selected.sha256,
|
||||
format: "file",
|
||||
},
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
limitations: [
|
||||
"Fields are TShark dissector observations under the selected filter/decode-as/pass settings, not independently verified protocol semantics. Missing fields are absent from the producer output, not known zero or empty values.",
|
||||
"Packet limits count input packets before display filtering. Filtered results do not establish whole-capture or device traffic absence. Two-pass reassembly cannot recover bytes missing from the recording or beyond the read limit.",
|
||||
"Repeated field values remain ordered string arrays; raw packet bytes are included only when explicitly selected as a supported field. Export index/score metadata are omitted. All stderr, including warnings on exit zero, is retained.",
|
||||
"A private empty personal configuration is used. Installed libraries, global configuration and dissector plugins are trusted and not fully fingerprinted. File-only analysis and disabled name resolution do not constitute an OS sandbox.",
|
||||
"Linux prlimit enforces soft and hard ceilings before execution, preserving stricter inherited limits: at most 1 GiB address space per process, 64 MiB per file, 30 CPU seconds and no core dumps. Effective limits are reported. Resident input is capped at 32 MiB, each command at 30 wall-clock seconds and combined output at 4 MiB. Output overflow fails. These are per-process/per-file limits, not aggregate memory or filesystem quotas; caller-supplied tools and the limiter are trusted.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (
|
||||
root !== undefined &&
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.cleanupOwner !== undefined
|
||||
)
|
||||
this.#resources.retain(root, cause.cleanupOwner);
|
||||
this.#resources.retain(root, cause.cleanupOwner, operation);
|
||||
result = err(
|
||||
this.#failure(
|
||||
cause,
|
||||
|
|
@ -360,18 +415,23 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
root?.path ?? null,
|
||||
effectiveLimits,
|
||||
capturedOutput,
|
||||
operation,
|
||||
limits,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.#resources.finish(result, (execution) =>
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
return this.#resources.finish(
|
||||
result,
|
||||
(execution) =>
|
||||
createEvidence(execution.subject ?? undefined, execution.provider, {
|
||||
operation,
|
||||
parameters: input,
|
||||
result: execution.result,
|
||||
confidence: "observed",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
operation,
|
||||
);
|
||||
}
|
||||
#failure(
|
||||
|
|
@ -386,6 +446,11 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
workspacePath: string | null,
|
||||
effectiveLimits: LinuxResourceLimits | undefined,
|
||||
observedOutput: AnalysisCapturedOutput | undefined,
|
||||
operation = OPERATION,
|
||||
limits: {
|
||||
readonly output_bytes: number;
|
||||
readonly command_timeout_ms: number;
|
||||
} = LIMITS,
|
||||
): AnalysisError {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined)
|
||||
|
|
@ -393,15 +458,34 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
cause.cleanup,
|
||||
cause,
|
||||
observedOutput,
|
||||
operation,
|
||||
);
|
||||
if (
|
||||
(cause instanceof Error && cause.name === "AbortError") ||
|
||||
(cause instanceof ArtifactReaderFailure && cause.reason === "cancelled")
|
||||
)
|
||||
return new AnalysisCancelledError(OPERATION, { cause });
|
||||
return new AnalysisCancelledError(operation, { cause });
|
||||
if (cause instanceof FieldDiscoveryBudgetError)
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
cause.message,
|
||||
{ record_budget_bytes: FIELD_RECORD_BUDGET },
|
||||
{
|
||||
cause,
|
||||
...(observedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: observedOutput }),
|
||||
},
|
||||
);
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
if (cause instanceof OwnedCommandFailure)
|
||||
return this.#resources.commandFailure(cause, LIMITS, effectiveLimits);
|
||||
return this.#resources.commandFailure(
|
||||
cause,
|
||||
limits,
|
||||
effectiveLimits,
|
||||
operation,
|
||||
);
|
||||
if (phase === "workspace") {
|
||||
const code =
|
||||
cause instanceof Error &&
|
||||
|
|
@ -411,13 +495,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
: null;
|
||||
if (code === "ENOSPC" || code === "EDQUOT" || code === "EFBIG")
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
operation,
|
||||
"file-size",
|
||||
`Private analysis workspace failed (${code}): ${reason}`,
|
||||
{ workspace_path: workspacePath, system_code: code },
|
||||
{ cause },
|
||||
);
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, operation, {
|
||||
cause,
|
||||
userMessage: `Cannot prepare the private analysis workspace: ${reason}`,
|
||||
diagnostics: {
|
||||
|
|
@ -440,12 +524,12 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
["limit", "integrity", "path"].includes(cause.reason)
|
||||
)
|
||||
return new AnalysisArtifactChangedError(
|
||||
OPERATION,
|
||||
operation,
|
||||
path,
|
||||
`Post-run identity verification failed for ${path}: ${reason}`,
|
||||
detail,
|
||||
);
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
return new ProviderAdapterError(TSHARK_PROVIDER_IDENTITY.id, operation, {
|
||||
...detail,
|
||||
diagnostics: { stage: "integrity", resource_path: path, reason },
|
||||
});
|
||||
|
|
@ -453,10 +537,13 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
if (phase === "configuration")
|
||||
return new AnalysisCapabilityUnavailableError(
|
||||
TSHARK_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
operation,
|
||||
reason,
|
||||
{
|
||||
cause,
|
||||
...(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).`,
|
||||
},
|
||||
);
|
||||
|
|
@ -466,14 +553,14 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
cause.reason === "limit"
|
||||
)
|
||||
return new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
operation,
|
||||
"file-size",
|
||||
reason,
|
||||
{ input_bytes: LIMITS.input_bytes },
|
||||
{ cause },
|
||||
);
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(OPERATION, path, reason, {
|
||||
return new AnalysisArtifactChangedError(operation, path, reason, {
|
||||
cause,
|
||||
});
|
||||
if (
|
||||
|
|
@ -482,13 +569,18 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
|||
"code" in cause &&
|
||||
(cause.code === "EACCES" || cause.code === "EPERM")
|
||||
)
|
||||
return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
|
||||
return new AnalysisAccessDeniedError(operation, path, cause.code, {
|
||||
cause,
|
||||
});
|
||||
if (phase === "artifact")
|
||||
return new AnalysisInputError(OPERATION, { cause }, [
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_value", message: reason },
|
||||
]);
|
||||
return new AnalysisOutputError(OPERATION, reason, { cause });
|
||||
return new AnalysisOutputError(operation, reason, {
|
||||
cause,
|
||||
...(observedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: observedOutput }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import type {
|
||||
InspectPacketFieldsInput,
|
||||
PacketFieldRecord,
|
||||
} from "../domain/captures/packetFields.js";
|
||||
|
||||
export class FieldDiscoveryBudgetError extends Error {}
|
||||
export const FIELD_RECORD_BUDGET = 8 * 1024 * 1024;
|
||||
|
||||
/** TShark -G fields: P/name/abbreviation or F/name/abbreviation/type/parent/base/mask/blurb. */
|
||||
export const parseFieldDiscovery = (
|
||||
text: string,
|
||||
input: InspectPacketFieldsInput,
|
||||
) => {
|
||||
const copy = (value: string) =>
|
||||
Buffer.from(value, "utf16le").toString("utf16le");
|
||||
const query = input.query.toLowerCase();
|
||||
const records: PacketFieldRecord[] = [];
|
||||
let examined = 0,
|
||||
matching = 0,
|
||||
sourceLine = 0,
|
||||
budget = 0;
|
||||
// Walk the bounded text once; allocate record objects only for the requested page.
|
||||
for (let start = 0; start < text.length;) {
|
||||
const end = text.indexOf("\n", start);
|
||||
let line = text.slice(start, end < 0 ? text.length : end);
|
||||
start = end < 0 ? text.length : end + 1;
|
||||
sourceLine++;
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||
const columns = line.split("\t", 9);
|
||||
const [tag, name, abbreviation] = columns;
|
||||
if (
|
||||
!(
|
||||
(tag === "P" && columns.length === 3) ||
|
||||
(tag === "F" && columns.length === 8)
|
||||
) ||
|
||||
name === undefined ||
|
||||
abbreviation === undefined ||
|
||||
abbreviation === ""
|
||||
)
|
||||
throw new Error(
|
||||
`Malformed TShark field glossary record at line ${sourceLine}`,
|
||||
);
|
||||
if ((input.kind === "protocols") !== (tag === "P")) continue;
|
||||
examined++;
|
||||
if (
|
||||
input.protocol !== null &&
|
||||
(tag === "P" ? abbreviation : columns[4]) !== input.protocol
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
![name, abbreviation, ...(tag === "F" ? [columns[7] ?? ""] : [])].some(
|
||||
(value) => value.toLowerCase().includes(query),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
matching++;
|
||||
if (
|
||||
matching <= input.offset ||
|
||||
records.length >= (input.limit ?? Number.MAX_SAFE_INTEGER)
|
||||
)
|
||||
continue;
|
||||
// Six bytes per source byte covers worst-case JSON escaping, plus record bookkeeping.
|
||||
budget += Buffer.byteLength(line, "utf8") * 6 + 512;
|
||||
if (budget > FIELD_RECORD_BUDGET)
|
||||
throw new FieldDiscoveryBudgetError(
|
||||
"Selected glossary records exceed the 8 MiB representation budget; narrow the query or page size",
|
||||
);
|
||||
// Detach retained fields from the much larger producer string.
|
||||
records.push({
|
||||
source_line: sourceLine,
|
||||
kind: tag === "P" ? "protocol" : "field",
|
||||
name: copy(name),
|
||||
abbreviation: copy(abbreviation),
|
||||
field_type: tag === "F" ? copy(columns[3] ?? "") : null,
|
||||
parent_protocol: tag === "F" ? copy(columns[4] ?? "") : null,
|
||||
display_base: tag === "F" ? copy(columns[5] ?? "") : null,
|
||||
bitmask: tag === "F" ? copy(columns[6] ?? "") : null,
|
||||
description: tag === "F" ? copy(columns[7] ?? "") : null,
|
||||
});
|
||||
}
|
||||
if (sourceLine === 0)
|
||||
throw new Error("TShark returned an empty field glossary");
|
||||
return {
|
||||
records,
|
||||
output_window: {
|
||||
examined,
|
||||
matching,
|
||||
returned: records.length,
|
||||
has_more: Math.max(0, matching - input.offset) > records.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { inspectPacketFieldsInputSchema } from "../domain/captures/packetFields.js";
|
||||
import { z } from "incur";
|
||||
import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
|
|
@ -22,9 +23,73 @@ export const registerCaptureCommands = (
|
|||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
service: Pick<
|
||||
PacketCaptureService,
|
||||
"inspect" | "close"
|
||||
"inspect" | "inspectFields" | "close"
|
||||
> = createPacketCaptureService(environment),
|
||||
): void => {
|
||||
const finish = async (
|
||||
result: Awaited<ReturnType<PacketCaptureService["inspect"]>>,
|
||||
operation: string,
|
||||
) => {
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"tshark-capture",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: operation },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(result.error, cleanup, operation),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
};
|
||||
cli.command(CLI_COMMANDS.inspectPacketFields, {
|
||||
description: "Search installed TShark protocol and field definitions",
|
||||
options: z
|
||||
.object({
|
||||
...inspectPacketFieldsInputSchema.shape,
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.optional()
|
||||
.describe("Maximum matching records to return; defaults to 50"),
|
||||
all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Return all remaining matches within resource limits"),
|
||||
})
|
||||
.superRefine((options, context) => {
|
||||
if (options.all && options.limit !== undefined)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["all"],
|
||||
message: "all and limit are mutually exclusive",
|
||||
});
|
||||
}),
|
||||
run: ({ options: { all, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectPacketFields, async () =>
|
||||
finish(
|
||||
await service.inspectFields(
|
||||
{ ...options, ...(all ? { limit: null } : {}) },
|
||||
{ signal },
|
||||
),
|
||||
"inspect_packet_fields",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectPacketCapture, {
|
||||
description:
|
||||
"Inspect an existing packet capture with caller-supplied TShark",
|
||||
|
|
@ -68,31 +133,7 @@ export const registerCaptureCommands = (
|
|||
},
|
||||
{ signal },
|
||||
);
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"tshark-capture",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: "inspect_packet_capture" },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
"inspect_packet_capture",
|
||||
),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
return finish(result, "inspect_packet_capture");
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { inspectSignalDecodersInputSchema } from "../domain/signals/signalDecoders.js";
|
||||
import { z } from "incur";
|
||||
import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
|
|
@ -22,9 +23,75 @@ export const registerSignalCommands = (
|
|||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
service: Pick<
|
||||
SignalRecordingService,
|
||||
"decode" | "close"
|
||||
"decode" | "inspectDecoders" | "close"
|
||||
> = createSignalRecordingService(environment),
|
||||
): void => {
|
||||
const finish = async (
|
||||
result: Awaited<ReturnType<SignalRecordingService["decode"]>>,
|
||||
operation: string,
|
||||
) => {
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"sigrok-recording",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: operation },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(result.error, cleanup, operation),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
};
|
||||
cli.command(CLI_COMMANDS.inspectSignalDecoders, {
|
||||
description: "Search installed sigrok decoders or selected decoder help",
|
||||
options: z
|
||||
.object({
|
||||
...inspectSignalDecodersInputSchema.shape,
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.optional()
|
||||
.describe(
|
||||
"Maximum matching decoders or help lines to return; defaults to 50",
|
||||
),
|
||||
all: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Return all remaining matches"),
|
||||
})
|
||||
.superRefine((options, context) => {
|
||||
if (options.all && options.limit !== undefined)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["all"],
|
||||
message: "all and limit are mutually exclusive",
|
||||
});
|
||||
}),
|
||||
run: ({ options: { all, ...options } }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectSignalDecoders, async () =>
|
||||
finish(
|
||||
await service.inspectDecoders(
|
||||
{ ...options, ...(all ? { limit: null } : {}) },
|
||||
{ signal },
|
||||
),
|
||||
"inspect_signal_decoders",
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.decodeSignalRecording, {
|
||||
description:
|
||||
"Decode an existing signal recording with caller-supplied sigrok",
|
||||
|
|
@ -68,31 +135,7 @@ export const registerSignalCommands = (
|
|||
},
|
||||
{ signal },
|
||||
);
|
||||
// A one-shot command must retry retained cleanup before discarding its owner.
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
const cleanup =
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderCleanupError(
|
||||
"sigrok-recording",
|
||||
[],
|
||||
{ reason: String(cause) },
|
||||
{ cause, operation: "decode_signal_recording" },
|
||||
);
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
return projectAnalysisError(
|
||||
result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
"decode_signal_recording",
|
||||
),
|
||||
);
|
||||
}
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
return finish(result, "decode_signal_recording");
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/** Canonical names used by every one-shot CLI command registration. */
|
||||
export const CLI_COMMANDS = Object.freeze({
|
||||
inspectPacketFields: "inspect-packet-fields",
|
||||
inspectPacketCapture: "inspect-packet-capture",
|
||||
inspectSignalDecoders: "inspect-signal-decoders",
|
||||
decodeSignalRecording: "decode-signal-recording",
|
||||
inspectWasmArtifact: "inspect-wasm-artifact",
|
||||
inspectSqliteDatabase: "inspect-sqlite-database",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,18 @@ import { describe, expect, it } from "vitest";
|
|||
import { parseConfig } from "./config/parseConfig.js";
|
||||
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
|
||||
|
||||
it("reports a configuration failure for a NUL in Hopper loader arguments", () => {
|
||||
const result = parseConfig({
|
||||
HOPPER_LOADER_ARGS_JSON: JSON.stringify(["--option", "value\0suffix"]),
|
||||
});
|
||||
if (result.ok)
|
||||
throw new Error("NUL argv cannot be passed to a child process");
|
||||
expect(result.error).toMatchObject({
|
||||
settings: [{ setting: "HOPPER_LOADER_ARGS_JSON" }],
|
||||
});
|
||||
expect(result.error.message).toContain("NUL");
|
||||
});
|
||||
|
||||
describe("runtime configuration", () => {
|
||||
it("allows target-free startup and applies runtime defaults", () => {
|
||||
const empty = parseConfig({});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { nulFreeStringSchema } from "../domain/localPath.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { safeParseJson } from "../domain/safeJson.js";
|
||||
|
||||
|
|
@ -20,7 +21,8 @@ export const parseLoaderArgs = (
|
|||
return parseConfiguredStringArray(
|
||||
encoded,
|
||||
"HOPPER_LOADER_ARGS_JSON",
|
||||
z.string(),
|
||||
nulFreeStringSchema,
|
||||
"must encode an array of strings without NUL bytes",
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -28,6 +30,7 @@ const parseConfiguredStringArray = (
|
|||
encoded: string,
|
||||
setting: string,
|
||||
itemSchema: z.ZodString,
|
||||
requirement = "must encode an array of strings",
|
||||
): Result<readonly string[], ConfigurationError> => {
|
||||
const decoded = safeParseJson(encoded);
|
||||
if (!decoded.ok)
|
||||
|
|
@ -37,13 +40,7 @@ const parseConfiguredStringArray = (
|
|||
const parsed = z.array(itemSchema).safeParse(decoded.value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
configuredArrayError(
|
||||
setting,
|
||||
"must encode an array of strings",
|
||||
parsed.error,
|
||||
),
|
||||
);
|
||||
: err(configuredArrayError(setting, requirement, parsed.error));
|
||||
};
|
||||
|
||||
const configuredArrayError = (
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
inspectPacketFieldsInputSchema,
|
||||
packetFieldsCatalogSchema,
|
||||
} from "../../domain/captures/packetFields.js";
|
||||
import {
|
||||
inspectPacketCaptureInputSchema,
|
||||
packetCaptureSchema,
|
||||
|
|
@ -6,6 +10,22 @@ import type { ToolContract } from "../toolContractTypes.js";
|
|||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
export const CAPTURE_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_packet_fields",
|
||||
...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.",
|
||||
inputSchema: inspectPacketFieldsInputSchema,
|
||||
outputSchema: evidenceResultOf(packetFieldsCatalogSchema),
|
||||
examples: [
|
||||
{ title: "Find USB protocol registrations", input: { query: "usb" } },
|
||||
{
|
||||
title: "Find the installed USB vendor-ID field",
|
||||
input: { kind: "fields", protocol: "usb", query: "idVendor" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_packet_capture",
|
||||
...toolContractMetadata("inspect_packet_capture"),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { jsonValueSchema } from "../../domain/jsonValue.js";
|
|||
import { localPathStringSchema } from "../../domain/localPath.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { requireOutputSchema } from "../toolOutputSchemaPrimitives.js";
|
||||
import { analysisDetailSchema } from "../analysisDetail.js";
|
||||
|
||||
const examples: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
|
||||
inspect_macho: {},
|
||||
|
|
@ -94,8 +95,15 @@ export const NATIVE_TOOL_CONTRACTS = [
|
|||
),
|
||||
native(
|
||||
"inspect_macho",
|
||||
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper.",
|
||||
z.object({}),
|
||||
"Inspect Mach-O slices, load commands, imports, exports, dependencies, build metadata, segments, sections, permissions, and exact command provenance without launching Hopper. Large frameworks return megabytes of symbols; detail summary retains the complete Evidence and returns slice identity with per-collection counts, then page rows with inspect_analysis_view kind macho.",
|
||||
z.object({
|
||||
detail: analysisDetailSchema
|
||||
.unwrap()
|
||||
.optional()
|
||||
.describe(
|
||||
"Omitted or complete returns the complete inspection Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for macho pages.",
|
||||
),
|
||||
}),
|
||||
),
|
||||
native(
|
||||
"inspect_signature",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
inspectSignalDecodersInputSchema,
|
||||
signalDecodersSchema,
|
||||
} from "../../domain/signals/signalDecoders.js";
|
||||
import {
|
||||
decodeSignalRecordingInputSchema,
|
||||
signalRecordingSchema,
|
||||
|
|
@ -6,6 +10,22 @@ import type { ToolContract } from "../toolContractTypes.js";
|
|||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
export const SIGNAL_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_signal_decoders",
|
||||
...toolContractMetadata("inspect_signal_decoders"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Discover installed sigrok protocol decoders or inspect one decoder's channel/option help on Linux. Omit decoder to list ID/name pairs; supply its exact ID for literal help lines with source line numbers. query is a case-insensitive literal substring. Page matches with offset/limit (default 50; null returns all remaining). Reports tool identity, version, diagnostics and examined/matching coverage. Uses configured REA_SIGROK_COMMAND and prlimit; no recording, binary target, hardware scan or acquisition. Each call probes again; decoder modules are trusted, not fully fingerprinted. Use returned IDs/options with decode_signal_recording.",
|
||||
inputSchema: inspectSignalDecodersInputSchema,
|
||||
outputSchema: evidenceResultOf(signalDecodersSchema),
|
||||
examples: [
|
||||
{ title: "Find installed UART decoders", input: { query: "uart" } },
|
||||
{
|
||||
title: "Read the installed UART baud-rate option",
|
||||
input: { decoder: "uart", query: "baud" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "decode_signal_recording",
|
||||
...toolContractMetadata("decode_signal_recording"),
|
||||
|
|
|
|||
|
|
@ -59,11 +59,21 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
|||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
inspect_packet_fields: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_packet_capture: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_signal_decoders: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
decode_signal_recording: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
artifactInventorySummaryViewResultSchema,
|
||||
machoSummaryViewResultSchema,
|
||||
managedArtifactSummaryViewResultSchema,
|
||||
} from "../domain/analysisView/analysisView.js";
|
||||
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
|
||||
|
|
@ -381,7 +382,9 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||
observe_native_ui: evidenceResultOf(nativeUiResultSchema),
|
||||
capture_native_ui_scenario: evidenceResultOf(nativeUiResultSchema),
|
||||
observe_native_calls: evidenceResultOf(nativeCallObservationResultSchema),
|
||||
inspect_macho: evidenceResultOf(inspectMachoSchema),
|
||||
inspect_macho: evidenceResultOf(
|
||||
z.union([inspectMachoSchema, machoSummaryViewResultSchema]),
|
||||
),
|
||||
inspect_signature: evidenceResultOf(inspectSignatureSchema),
|
||||
inspect_plist: evidenceResultOf(inspectPlistSchema),
|
||||
list_architectures: evidenceResultOf(listArchitecturesSchema),
|
||||
|
|
|
|||
|
|
@ -30,6 +30,25 @@ const packageNameSchema = z
|
|||
"Package name must be dot-separated Java identifiers",
|
||||
);
|
||||
|
||||
/** Text states emitted by adb's ConnectionState formatter. */
|
||||
export const ADB_DEVICE_STATES = [
|
||||
"no permissions",
|
||||
"unauthorized",
|
||||
"authorizing",
|
||||
"offline",
|
||||
"recovery",
|
||||
"sideload",
|
||||
"connecting",
|
||||
"bootloader",
|
||||
"device",
|
||||
"unknown",
|
||||
"host",
|
||||
"rescue",
|
||||
"detached",
|
||||
] as const;
|
||||
export const adbDeviceStateSchema = z.enum(ADB_DEVICE_STATES);
|
||||
export type AdbDeviceState = (typeof ADB_DEVICE_STATES)[number];
|
||||
|
||||
const keyStringSchema = z.string().min(1).max(256);
|
||||
const keyPattern = /^[A-Za-z0-9._\x2d]+$/u;
|
||||
|
||||
|
|
@ -412,7 +431,7 @@ const client = z.strictObject({
|
|||
|
||||
const device = z.strictObject({
|
||||
serial: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
state: adbDeviceStateSchema,
|
||||
transport: z.enum(["usb", "tcp", "unknown"]),
|
||||
product: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ import {
|
|||
wasmViewItemSchema,
|
||||
wasmSummarySchema,
|
||||
} from "./wasmArtifactView.js";
|
||||
import {
|
||||
machoSummarySchema,
|
||||
machoViewContextSchema,
|
||||
machoViewItemSchema,
|
||||
machoViewRequestSchema,
|
||||
projectMachoView,
|
||||
} from "./machoView.js";
|
||||
import {
|
||||
projectWebNetworkCaptureView,
|
||||
captureViewRequestSchema,
|
||||
|
|
@ -196,6 +203,7 @@ export const analysisViewRequestSchema = z.discriminatedUnion("kind", [
|
|||
nativeViewSchema,
|
||||
inventoryViewSchema,
|
||||
wasmViewRequestSchema,
|
||||
machoViewRequestSchema,
|
||||
evmViewRequestSchema,
|
||||
managedViewRequestSchema,
|
||||
androidViewRequestSchema,
|
||||
|
|
@ -315,6 +323,7 @@ const viewResultBase = {
|
|||
"inspect_artifact",
|
||||
"inventory_artifact",
|
||||
"inspect_wasm_artifact",
|
||||
"inspect_macho",
|
||||
"inspect_evm_interface",
|
||||
"inspect_managed_artifact",
|
||||
"inspect_web_network_capture",
|
||||
|
|
@ -364,6 +373,13 @@ export const wasmArtifactSummaryViewResultSchema = z.strictObject({
|
|||
summary: wasmSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact slice identity and collection counts for Mach-O's selected delivery. */
|
||||
export const machoSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
parent_operation: z.literal("inspect_macho"),
|
||||
summary: machoSummarySchema,
|
||||
});
|
||||
|
||||
/** Exact inferred-interface summary for EVM's caller-selected delivery. */
|
||||
export const evmInterfaceSummaryViewResultSchema = z.strictObject({
|
||||
...summaryViewResultBase,
|
||||
|
|
@ -425,6 +441,7 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||
javascriptSummarySchema,
|
||||
inventorySummarySchema,
|
||||
wasmSummarySchema,
|
||||
machoSummarySchema,
|
||||
evmSummarySchema,
|
||||
managedSummarySchema,
|
||||
captureSummarySchema,
|
||||
|
|
@ -487,6 +504,14 @@ export const analysisViewResultSchema = z.discriminatedUnion("kind", [
|
|||
item: wasmViewItemSchema,
|
||||
...viewResultBase,
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("macho"),
|
||||
view: machoViewRequestSchema,
|
||||
item: machoViewItemSchema,
|
||||
...viewResultBase,
|
||||
...machoViewContextSchema.shape,
|
||||
parent_operation: z.literal("inspect_macho"),
|
||||
}),
|
||||
z.strictObject({
|
||||
kind: z.literal("evm"),
|
||||
view: evmViewRequestSchema,
|
||||
|
|
@ -583,6 +608,7 @@ export type UnsignedAnalysisView =
|
|||
"view_digest"
|
||||
>
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "wasm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "macho" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "evm" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "qlog" }>, "view_digest">
|
||||
| Omit<Extract<AnalysisViewResult, { readonly kind: "pcap" }>, "view_digest">
|
||||
|
|
@ -646,7 +672,7 @@ const unsupportedParent = (
|
|||
new AnalysisUnsupportedTargetError(
|
||||
INSPECT_ANALYSIS_VIEW_OPERATION,
|
||||
parentArtifactPath(parent),
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
`Parent Evidence operation ${JSON.stringify(parent.operation)} does not support analysis views. Use completed binary layout, JavaScript application, native function, artifact inventory/inspection, WASM, Mach-O, EVM, managed artifact, Android package, Dart AOT, or web network capture Evidence.`,
|
||||
);
|
||||
|
||||
const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
||||
|
|
@ -673,6 +699,7 @@ const parentArtifactPath = (parent: AnalysisViewParent): string => {
|
|||
/** Named source owners for views that need no extra decoder or accumulator. */
|
||||
const retainedViewProjectors = new Map<string, typeof projectWasmArtifactView>([
|
||||
["inspect_wasm_artifact", projectWasmArtifactView],
|
||||
["inspect_macho", projectMachoView],
|
||||
["inspect_evm_interface", projectEvmInterfaceView],
|
||||
["inspect_managed_artifact", projectManagedArtifactView],
|
||||
["inspect_android_package", projectAndroidPackageView],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
projectAnalysisView,
|
||||
type AnalysisViewParent,
|
||||
} from "./analysisView.js";
|
||||
|
||||
const symbol = (name: string) => ({
|
||||
name,
|
||||
address: null,
|
||||
weak: null,
|
||||
reexport: null,
|
||||
source: "libSystem",
|
||||
});
|
||||
const coverage = <T>(items: T[], exhaustive = true) => ({
|
||||
items,
|
||||
total: exhaustive ? items.length : null,
|
||||
exhaustive,
|
||||
limitations: exhaustive ? [] : ["nm output may omit stripped symbols."],
|
||||
});
|
||||
const report = {
|
||||
format: "mach-o",
|
||||
selected_architecture: { name: "arm64", container_file_offset: 0 },
|
||||
endian: null,
|
||||
word_size: 64,
|
||||
file_type: "2",
|
||||
flags: ["0x00200085"],
|
||||
uuid: "4C4C44E9-5555-3144-A1E9-D858E24A1CCE",
|
||||
entrypoints: coverage([{ file_offset: 16384 }]),
|
||||
architectures: coverage([
|
||||
{
|
||||
name: "arm64",
|
||||
cpu_type: "16777228",
|
||||
cpu_subtype: "0",
|
||||
file_offset: 0,
|
||||
size: 4096,
|
||||
alignment: 14,
|
||||
},
|
||||
]),
|
||||
build_metadata: coverage([
|
||||
{ platform: "1", minimum_os: "14.0", sdk: "15.0", tools: [] },
|
||||
]),
|
||||
load_commands: coverage([{ index: 0, kind: "LC_MAIN", fields: {} }]),
|
||||
dependencies: coverage([
|
||||
{
|
||||
path: "/usr/lib/libSystem.B.dylib",
|
||||
kind: "LC_LOAD_DYLIB",
|
||||
current_version: "1351.0.0",
|
||||
compatibility_version: "1.0.0",
|
||||
},
|
||||
]),
|
||||
imports: coverage(
|
||||
["_malloc", "_free", "_open"].map((name) => symbol(name)),
|
||||
false,
|
||||
),
|
||||
exports: coverage([symbol("__mh_execute_header")]),
|
||||
segments: coverage([
|
||||
{
|
||||
name: "__TEXT",
|
||||
vm_address: "0x100000000",
|
||||
vm_size: 16384,
|
||||
file_offset: 0,
|
||||
file_size: 16384,
|
||||
maximum_permissions: {
|
||||
read: true,
|
||||
write: false,
|
||||
execute: true,
|
||||
raw: "5",
|
||||
},
|
||||
initial_permissions: {
|
||||
read: true,
|
||||
write: false,
|
||||
execute: true,
|
||||
raw: "5",
|
||||
},
|
||||
sections: coverage([]),
|
||||
},
|
||||
]),
|
||||
provenance: [
|
||||
{
|
||||
tool: "nm",
|
||||
command: ["/usr/bin/nm", "$ARTIFACT"],
|
||||
tool_version: null,
|
||||
version_reason: "fixture",
|
||||
executable_sha256: "a".repeat(64),
|
||||
exit: { code: 0, signal: null },
|
||||
stdout_bytes: 10,
|
||||
stderr_bytes: 0,
|
||||
},
|
||||
],
|
||||
limitations: ["Imports and exports combine dyld_info and nm."],
|
||||
};
|
||||
const parent = (
|
||||
normalizedResult: unknown = report,
|
||||
subject: AnalysisViewParent["subject"] = {
|
||||
name: "tool",
|
||||
digest: { sha256: "b".repeat(64) },
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
local_path: "/bin/tool",
|
||||
},
|
||||
): AnalysisViewParent => ({
|
||||
evidenceId: `ev_${"c".repeat(64)}`,
|
||||
operation: "inspect_macho",
|
||||
normalizedResult,
|
||||
subject,
|
||||
limitations: ["Parent limitation."],
|
||||
});
|
||||
|
||||
describe("Mach-O analysis views", () => {
|
||||
it("summarizes slice identity and collection counts without rows", () => {
|
||||
const projected = projectAnalysisView(parent(), { kind: "summary" });
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value).toMatchObject({
|
||||
kind: "summary",
|
||||
parent_operation: "inspect_macho",
|
||||
artifact: { path: "/bin/tool", sha256: "b".repeat(64) },
|
||||
summary: {
|
||||
selected_architecture: { name: "arm64", container_file_offset: 0 },
|
||||
uuid: report.uuid,
|
||||
collections: {
|
||||
imports: { returned: 3, total: null, exhaustive: false },
|
||||
exports: { returned: 1, total: 1, exhaustive: true },
|
||||
segments: { returned: 1, total: 1, exhaustive: true },
|
||||
},
|
||||
provenance_count: 1,
|
||||
limitation_count: 3,
|
||||
},
|
||||
coverage: { status: "complete-within-view", examined: 10, total: 10 },
|
||||
limitations: [
|
||||
"Imports and exports combine dyld_info and nm.",
|
||||
"Parent limitation.",
|
||||
"nm output may omit stripped symbols.",
|
||||
],
|
||||
unknowns: [expect.stringContaining("exhaustive imports;")],
|
||||
});
|
||||
expect(JSON.stringify(projected.value)).not.toContain("_malloc");
|
||||
});
|
||||
|
||||
it("pages original rows with the producer's own coverage", () => {
|
||||
const first = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "imports",
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
});
|
||||
if (!first.ok) throw first.error;
|
||||
expect(first.value).toMatchObject({
|
||||
kind: "macho",
|
||||
item: [symbol("_free")],
|
||||
source_total: null,
|
||||
source_exhaustive: false,
|
||||
coverage: { status: "page", examined: 1, total: 3, next_offset: 2 },
|
||||
});
|
||||
expect(first.value.limitations).toContain(
|
||||
"nm output may omit stripped symbols.",
|
||||
);
|
||||
// Rows whose keys include an earlier row shape's keys stay whole.
|
||||
for (const facet of ["architectures", "segments"] as const) {
|
||||
const page = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet,
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!page.ok) throw page.error;
|
||||
expect(page.value.kind === "macho" && page.value.item).toEqual(
|
||||
report[facet].items,
|
||||
);
|
||||
}
|
||||
const provenance = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "provenance",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (!provenance.ok) throw provenance.error;
|
||||
expect(provenance.value).toMatchObject({
|
||||
item: report.provenance,
|
||||
source_total: 1,
|
||||
source_exhaustive: true,
|
||||
coverage: { exhausted: true, next_offset: null },
|
||||
});
|
||||
const past = projectAnalysisView(parent(), {
|
||||
kind: "macho",
|
||||
facet: "exports",
|
||||
offset: 5,
|
||||
limit: 64,
|
||||
});
|
||||
if (!past.ok) throw past.error;
|
||||
expect(past.value.coverage).toMatchObject({
|
||||
status: "empty",
|
||||
exhausted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a missing subject unknown instead of inventing identity", () => {
|
||||
const projected = projectAnalysisView(parent(report, null), {
|
||||
kind: "summary",
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value.artifact).toEqual({ path: "", sha256: null });
|
||||
expect(projected.value.unknowns).toContain(
|
||||
"The parent has no recorded subject, so the inspected artifact path and SHA-256 are unknown.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects other view kinds and malformed retained results", () => {
|
||||
const wrongKind = projectAnalysisView(parent(), {
|
||||
kind: "wasm",
|
||||
facet: "exports",
|
||||
offset: 0,
|
||||
limit: 64,
|
||||
});
|
||||
if (wrongKind.ok) throw new Error("expected a view-kind refusal");
|
||||
expect(wrongKind.error).toMatchObject({
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
{
|
||||
path: ["view"],
|
||||
message: "Mach-O inspection supports summary and macho views.",
|
||||
},
|
||||
],
|
||||
});
|
||||
const contradictory = projectAnalysisView(
|
||||
parent(report, {
|
||||
name: "tool",
|
||||
digest: { sha256: "b".repeat(64) },
|
||||
format: "elf",
|
||||
architecture: null,
|
||||
local_path: "/bin/tool",
|
||||
}),
|
||||
{ kind: "summary" },
|
||||
);
|
||||
if (contradictory.ok) throw new Error("expected a subject refusal");
|
||||
expect(contradictory.error).toMatchObject({
|
||||
issues: [{ path: ["source", "subject"] }],
|
||||
});
|
||||
const malformed = projectAnalysisView(
|
||||
parent({ ...report, imports: { items: "x" } }),
|
||||
{ kind: "summary" },
|
||||
);
|
||||
expect(malformed.ok).toBe(false);
|
||||
if (!malformed.ok) expect(malformed.error._tag).toBe("AnalysisInputError");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
import { z } from "zod";
|
||||
import type { AnalysisError } from "../analysisErrorBase.js";
|
||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||
import { isMachOFormat } from "../binaryTargetTypes.js";
|
||||
import { analysisInputErrorFromIssues } from "../inputIssueProjection.js";
|
||||
import { inspectMachoSchema } from "../native/nativeInspection.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import {
|
||||
analysisViewInputError,
|
||||
type AnalysisViewParent,
|
||||
type AnalysisViewRequest,
|
||||
type UnsignedAnalysisView,
|
||||
} from "./analysisView.js";
|
||||
import {
|
||||
completeWithinViewCoverage,
|
||||
pageViewCoverage,
|
||||
} from "./analysisViewCoverage.js";
|
||||
|
||||
const MACHO_COLLECTIONS = [
|
||||
"entrypoints",
|
||||
"architectures",
|
||||
"build_metadata",
|
||||
"load_commands",
|
||||
"dependencies",
|
||||
"imports",
|
||||
"exports",
|
||||
"segments",
|
||||
] as const;
|
||||
type MachoCollection = (typeof MACHO_COLLECTIONS)[number];
|
||||
|
||||
/** Original inspect_macho rows; offsets count rows in the retained collection. */
|
||||
export const machoViewRequestSchema = z.strictObject({
|
||||
kind: z.literal("macho"),
|
||||
facet: z.enum([...MACHO_COLLECTIONS, "provenance"]),
|
||||
offset: z.number().int().nonnegative().default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
});
|
||||
|
||||
const { shape } = inspectMachoSchema;
|
||||
/**
|
||||
* Union members are tried in order and plain objects drop unknown keys, so a
|
||||
* segment or architecture row would otherwise match the entrypoint row and
|
||||
* lose every field but file_offset. Exact key sets keep each page's rows whole.
|
||||
*/
|
||||
const exactRows = <Shape extends z.ZodRawShape>(
|
||||
rows: z.ZodArray<z.ZodObject<Shape>>,
|
||||
) => z.array(z.strictObject(rows.element.shape));
|
||||
export const machoViewItemSchema = z.union([
|
||||
exactRows(shape.entrypoints.shape.items),
|
||||
exactRows(shape.architectures.shape.items),
|
||||
exactRows(shape.build_metadata.shape.items),
|
||||
exactRows(shape.load_commands.shape.items),
|
||||
exactRows(shape.dependencies.shape.items),
|
||||
// Imports and exports share one row shape.
|
||||
exactRows(shape.imports.shape.items),
|
||||
exactRows(shape.segments.shape.items),
|
||||
exactRows(shape.provenance),
|
||||
]);
|
||||
|
||||
/** The producer's own coverage of the paged collection, not of this page. */
|
||||
export const machoViewContextSchema = z.strictObject({
|
||||
source_total: z.number().int().nonnegative().nullable(),
|
||||
source_exhaustive: z.boolean(),
|
||||
});
|
||||
|
||||
const collectionCountSchema = z.strictObject({
|
||||
returned: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative().nullable(),
|
||||
exhaustive: z.boolean(),
|
||||
});
|
||||
|
||||
/** Slice identity and per-collection counts without the collections themselves. */
|
||||
export const machoSummarySchema = z.strictObject({
|
||||
format: shape.format,
|
||||
selected_architecture: shape.selected_architecture,
|
||||
endian: shape.endian,
|
||||
word_size: shape.word_size,
|
||||
file_type: shape.file_type,
|
||||
flags: shape.flags,
|
||||
uuid: shape.uuid,
|
||||
collections: z.strictObject(
|
||||
Object.fromEntries(
|
||||
MACHO_COLLECTIONS.map((name) => [name, collectionCountSchema]),
|
||||
) as Record<MachoCollection, typeof collectionCountSchema>,
|
||||
),
|
||||
provenance_count: z.number().int().nonnegative(),
|
||||
limitation_count: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
type MachoReport = z.output<typeof inspectMachoSchema>;
|
||||
|
||||
/** Admit a well-formed report whose recorded subject, if any, is a Mach-O. */
|
||||
const parseMachoReport = (
|
||||
parent: AnalysisViewParent,
|
||||
): Result<MachoReport, AnalysisError> => {
|
||||
const parsed = inspectMachoSchema.safeParse(parent.normalizedResult);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
"inspect_analysis_view",
|
||||
parsed.error.issues.map((issue) => ({
|
||||
...issue,
|
||||
path: ["source", "normalized_result", ...issue.path],
|
||||
})),
|
||||
{ source: { normalized_result: parent.normalizedResult } },
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
if (parent.subject != null && !isMachOFormat(parent.subject.format))
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["source", "subject"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"Mach-O parent Evidence subject must identify a thin or universal Mach-O.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
return ok(parsed.data);
|
||||
};
|
||||
|
||||
/** Counts replace rows; every collection caveat stays in the limitations. */
|
||||
const summarizeMacho = (
|
||||
report: MachoReport,
|
||||
parentLimitations: readonly string[],
|
||||
) => {
|
||||
const limitations = uniqueSorted([
|
||||
...parentLimitations,
|
||||
...report.limitations,
|
||||
...MACHO_COLLECTIONS.flatMap((name) => report[name].limitations),
|
||||
]);
|
||||
const collections = Object.fromEntries(
|
||||
MACHO_COLLECTIONS.map((name) => [
|
||||
name,
|
||||
{
|
||||
returned: report[name].items.length,
|
||||
total: report[name].total,
|
||||
exhaustive: report[name].exhaustive,
|
||||
},
|
||||
]),
|
||||
) as Record<MachoCollection, z.output<typeof collectionCountSchema>>;
|
||||
return {
|
||||
limitations,
|
||||
returned: MACHO_COLLECTIONS.reduce(
|
||||
(sum, name) => sum + report[name].items.length,
|
||||
0,
|
||||
),
|
||||
summary: {
|
||||
format: report.format,
|
||||
selected_architecture: report.selected_architecture,
|
||||
endian: report.endian,
|
||||
word_size: report.word_size,
|
||||
file_type: report.file_type,
|
||||
flags: report.flags,
|
||||
uuid: report.uuid,
|
||||
collections,
|
||||
provenance_count: report.provenance.length,
|
||||
limitation_count: limitations.length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Project retained inspect_macho Evidence without rerunning native tools. */
|
||||
export const projectMachoView = (
|
||||
parent: AnalysisViewParent,
|
||||
view: AnalysisViewRequest,
|
||||
): Result<UnsignedAnalysisView, AnalysisError> => {
|
||||
if (view.kind !== "summary" && view.kind !== "macho")
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message: "Mach-O inspection supports summary and macho views.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const parsed = parseMachoReport(parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
const report = parsed.value;
|
||||
const incomplete = MACHO_COLLECTIONS.filter(
|
||||
(name) => !report[name].exhaustive,
|
||||
);
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_macho" as const,
|
||||
parent_digest: parent.evidenceId.slice(3),
|
||||
artifact:
|
||||
parent.subject == null
|
||||
? { path: "", sha256: null }
|
||||
: {
|
||||
path: parent.subject.local_path,
|
||||
sha256: parent.subject.digest.sha256,
|
||||
},
|
||||
unknowns: [
|
||||
...(incomplete.length === 0
|
||||
? []
|
||||
: [
|
||||
`Native tools did not report exhaustive ${incomplete.join(", ")}; page exhaustion does not establish absence of unreturned rows.`,
|
||||
]),
|
||||
...(parent.subject == null
|
||||
? [
|
||||
"The parent has no recorded subject, so the inspected artifact path and SHA-256 are unknown.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
if (view.kind === "summary") {
|
||||
const { summary, limitations, returned } = summarizeMacho(
|
||||
report,
|
||||
parent.limitations,
|
||||
);
|
||||
return ok({
|
||||
kind: "summary",
|
||||
view,
|
||||
...shared,
|
||||
limitations,
|
||||
summary,
|
||||
coverage: completeWithinViewCoverage(returned, returned),
|
||||
});
|
||||
}
|
||||
const source =
|
||||
view.facet === "provenance"
|
||||
? {
|
||||
items: report.provenance,
|
||||
total: report.provenance.length,
|
||||
exhaustive: true,
|
||||
limitations: [],
|
||||
}
|
||||
: report[view.facet];
|
||||
const item = source.items.slice(view.offset, view.offset + view.limit);
|
||||
return ok({
|
||||
kind: "macho",
|
||||
view,
|
||||
item,
|
||||
...shared,
|
||||
limitations: uniqueSorted([
|
||||
...parent.limitations,
|
||||
...report.limitations,
|
||||
...source.limitations,
|
||||
]),
|
||||
source_total: source.total,
|
||||
source_exhaustive: source.exhaustive,
|
||||
coverage: pageViewCoverage(view.offset, item.length, source.items.length),
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { z } from "zod";
|
||||
import { packetCaptureSchema } from "./packetCapture.js";
|
||||
|
||||
export const inspectPacketFieldsInputSchema = z.strictObject({
|
||||
kind: z
|
||||
.enum(["protocols", "fields"])
|
||||
.default("protocols")
|
||||
.describe(
|
||||
"Return protocol registrations or field definitions; defaults to protocols",
|
||||
),
|
||||
protocol: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(8192)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
"Exact protocol abbreviation; selects that protocol or its registered fields",
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
.max(8192)
|
||||
.default("")
|
||||
.describe(
|
||||
"Case-insensitive literal substring in name, abbreviation or field description",
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe("Number of matching records to skip; defaults to 0"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable()
|
||||
.default(50)
|
||||
.describe(
|
||||
"Maximum matches returned; null requests all remaining within the record budget",
|
||||
),
|
||||
});
|
||||
export type InspectPacketFieldsInput = z.infer<
|
||||
typeof inspectPacketFieldsInputSchema
|
||||
>;
|
||||
export const packetFieldRecordSchema = z.strictObject({
|
||||
source_line: z.number().int().positive(),
|
||||
kind: z.enum(["protocol", "field"]),
|
||||
name: z.string(),
|
||||
abbreviation: z.string(),
|
||||
field_type: z.string().nullable(),
|
||||
parent_protocol: z.string().nullable(),
|
||||
display_base: z.string().nullable(),
|
||||
bitmask: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
});
|
||||
export type PacketFieldRecord = z.infer<typeof packetFieldRecordSchema>;
|
||||
export const packetFieldsCatalogSchema = z
|
||||
.strictObject({
|
||||
selection: inspectPacketFieldsInputSchema,
|
||||
records: z.array(packetFieldRecordSchema),
|
||||
output_window: z.strictObject({
|
||||
examined: z.number().int().nonnegative(),
|
||||
matching: z.number().int().nonnegative(),
|
||||
returned: z.number().int().nonnegative(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
diagnostics: z.string(),
|
||||
tool: packetCaptureSchema.shape.tool,
|
||||
resource_limiter: packetCaptureSchema.shape.resource_limiter,
|
||||
limits: packetCaptureSchema.shape.limits.extend({
|
||||
record_budget_bytes: z.literal(8388608),
|
||||
}),
|
||||
configuration: z.literal("private-empty-personal-profile"),
|
||||
hardware_acquisition: z.literal("not-performed"),
|
||||
})
|
||||
.superRefine((report, context) => {
|
||||
const w = report.output_window;
|
||||
const remaining = Math.max(0, w.matching - report.selection.offset);
|
||||
const expected = Math.min(remaining, report.selection.limit ?? remaining);
|
||||
if (
|
||||
w.matching > w.examined ||
|
||||
w.returned !== expected ||
|
||||
report.records.length !== expected ||
|
||||
w.has_more !== remaining > expected ||
|
||||
report.records.some(
|
||||
(row) =>
|
||||
row.kind !==
|
||||
(report.selection.kind === "protocols" ? "protocol" : "field"),
|
||||
)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["output_window"],
|
||||
message:
|
||||
"Field discovery coverage must match the selected page and record kind",
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,9 @@ const source = (digit: string): Evidence =>
|
|||
const left = source("1");
|
||||
const right = source("2");
|
||||
|
||||
const aggregate = (comparisons: readonly unknown[]) =>
|
||||
findChangedBehavior({ comparisons });
|
||||
|
||||
const comparison = (
|
||||
operation: "compare_process_captures" | "compare_artifacts",
|
||||
result: JsonValue,
|
||||
|
|
@ -136,9 +139,9 @@ const expectInvalidSummary = (result: ChangedBehaviorResult): void => {
|
|||
|
||||
describe("changed behavior", () => {
|
||||
it("accepts process comparison Evidence", () => {
|
||||
const result = findChangedBehavior([
|
||||
const result = aggregate([
|
||||
comparison("compare_process_captures", processResult()),
|
||||
]);
|
||||
]).result;
|
||||
expect(result.behavior_status).toBe("observed_unchanged");
|
||||
});
|
||||
|
||||
|
|
@ -152,7 +155,7 @@ describe("changed behavior", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
expect(findChangedBehavior([evidence])).toMatchObject({
|
||||
expect(aggregate([evidence]).result).toMatchObject({
|
||||
behavior_status: "observed_changed",
|
||||
summary: { observed_changes: 2 },
|
||||
findings: {
|
||||
|
|
@ -172,7 +175,7 @@ describe("changed behavior", () => {
|
|||
"compare_process_captures",
|
||||
processResult({ status: "changed", terminal: "changed" }),
|
||||
);
|
||||
const result = findChangedBehavior([evidence]);
|
||||
const result = aggregate([evidence]).result;
|
||||
expect(changedBehaviorResultSchema.parse(result)).toMatchObject({
|
||||
behavior_status: "observed_changed",
|
||||
summary: { observed_changes: 1, static_candidates: 0 },
|
||||
|
|
@ -193,7 +196,7 @@ describe("changed behavior", () => {
|
|||
|
||||
it("keeps static differences as candidates, never runtime observations", () => {
|
||||
const evidence = comparison("compare_artifacts", artifactResult());
|
||||
const result = findChangedBehavior([evidence]);
|
||||
const result = aggregate([evidence]).result;
|
||||
expect(result).toMatchObject({
|
||||
behavior_status: "unknown",
|
||||
summary: { observed_changes: 0, static_candidates: 1 },
|
||||
|
|
@ -217,17 +220,17 @@ describe("changed behavior", () => {
|
|||
"compare_process_captures",
|
||||
processResult({ status: "unknown", exit: "unknown" }),
|
||||
);
|
||||
const result = findChangedBehavior([changed, unknown]);
|
||||
const result = aggregate([changed, unknown]).result;
|
||||
expect(result).toMatchObject({
|
||||
behavior_status: "unknown",
|
||||
findings: { items: expect.any(Array) },
|
||||
});
|
||||
expect(findChangedBehavior([changed, unknown])).toEqual(result);
|
||||
expect(aggregate([changed, unknown]).result).toEqual(result);
|
||||
});
|
||||
|
||||
it("uses complete inline artifact deltas as static candidates", () => {
|
||||
const complete = comparison("compare_artifacts", artifactResult());
|
||||
expect(findChangedBehavior([complete]).findings.items).toEqual(
|
||||
expect(aggregate([complete]).result.findings.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ dimension: "artifact:main.js" }),
|
||||
]),
|
||||
|
|
@ -236,7 +239,7 @@ describe("changed behavior", () => {
|
|||
|
||||
it("rejects bundles, duplicate comparisons, and malformed exact results", () => {
|
||||
const valid = comparison("compare_process_captures", processResult());
|
||||
expect(() => findChangedBehavior([valid, valid])).toThrow(
|
||||
expect(() => aggregate([valid, valid])).toThrow(
|
||||
/duplicate comparison Evidence/u,
|
||||
);
|
||||
const bundle = createEvidence(
|
||||
|
|
@ -252,25 +255,25 @@ describe("changed behavior", () => {
|
|||
evidenceLinks: [left.evidence_id, right.evidence_id],
|
||||
},
|
||||
);
|
||||
expect(() => findChangedBehavior([bundle])).toThrow(
|
||||
expect(() => aggregate([bundle])).toThrow(
|
||||
/requires process, artifact, or function/u,
|
||||
);
|
||||
const malformed = comparison("compare_process_captures", {
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(() => findChangedBehavior([malformed])).toThrow();
|
||||
expect(() => aggregate([malformed])).toThrow();
|
||||
const contradictory = comparison(
|
||||
"compare_process_captures",
|
||||
processResult({ status: "unchanged", terminal: "changed" }),
|
||||
);
|
||||
expect(() => findChangedBehavior([contradictory])).toThrow(
|
||||
expect(() => aggregate([contradictory])).toThrow(
|
||||
/contradicts its dimensions/u,
|
||||
);
|
||||
const danglingNested = comparison(
|
||||
"compare_artifacts",
|
||||
artifactResult([`ev_${"f".repeat(64)}`, right.evidence_id]),
|
||||
);
|
||||
expect(() => findChangedBehavior([danglingNested])).toThrow(
|
||||
expect(() => aggregate([danglingNested])).toThrow(
|
||||
/outside its top-level closure/u,
|
||||
);
|
||||
});
|
||||
|
|
@ -299,10 +302,15 @@ describe("changed behavior aggregation", () => {
|
|||
),
|
||||
);
|
||||
|
||||
const result = findChangedBehavior(comparisons);
|
||||
const result = aggregate(comparisons);
|
||||
|
||||
expect(result.summary.observed_changes).toBe(comparisons.length);
|
||||
expect(result.evidence_links).toHaveLength(comparisons.length * 201 + 2);
|
||||
expect(result.findings.items[0]?.evidence_links).toHaveLength(203);
|
||||
expect(result.result.summary.observed_changes).toBe(comparisons.length);
|
||||
expect(result.result.evidence_links).toHaveLength(
|
||||
comparisons.length * 201 + 2,
|
||||
);
|
||||
expect(result.result.findings.items[0]?.evidence_links).toHaveLength(203);
|
||||
expect(result.comparisonEvidenceIds).toEqual(
|
||||
comparisons.map(({ evidence_id }) => evidence_id),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,6 +91,22 @@ export const changedBehaviorResultSchema = z
|
|||
export type ChangedBehaviorResult = z.infer<typeof changedBehaviorResultSchema>;
|
||||
type Finding = z.infer<typeof findingSchema>;
|
||||
type RuntimeStatus = z.infer<typeof processCaptureComparisonSchema>["status"];
|
||||
type ParsedComparison =
|
||||
| {
|
||||
readonly operation: "compare_process_captures";
|
||||
readonly evidence: Evidence;
|
||||
readonly result: z.infer<typeof processCaptureComparisonSchema>;
|
||||
}
|
||||
| {
|
||||
readonly operation: "compare_functions";
|
||||
readonly evidence: Evidence;
|
||||
readonly result: z.infer<typeof functionComparisonResultSchema>;
|
||||
}
|
||||
| {
|
||||
readonly operation: "compare_artifacts";
|
||||
readonly evidence: Evidence;
|
||||
readonly result: z.infer<typeof artifactComparisonResultSchema>;
|
||||
};
|
||||
|
||||
const EXPECTED_PROVIDERS = {
|
||||
compare_process_captures: {
|
||||
|
|
@ -117,24 +133,37 @@ const acceptsPredicate = (
|
|||
): boolean => expected.some((predicate) => predicate === actual);
|
||||
|
||||
/** Find observed runtime changes and separately report static change candidates. */
|
||||
export const findChangedBehavior = (
|
||||
comparisonsInput: unknown,
|
||||
): ChangedBehaviorResult => {
|
||||
const parsedInput = changedBehaviorInputSchema.parse({
|
||||
comparisons: comparisonsInput,
|
||||
});
|
||||
const comparisons = parsedInput.comparisons;
|
||||
interface FoundChangedBehavior {
|
||||
readonly result: ChangedBehaviorResult;
|
||||
/** Authenticated IDs retained from the comparisons used to compute result. */
|
||||
readonly comparisonEvidenceIds: readonly string[];
|
||||
}
|
||||
|
||||
export const findChangedBehavior = (input: unknown): FoundChangedBehavior => {
|
||||
// The public input schema remains available to clients; this internal
|
||||
// admission only checks the list shape because parseEvidence below owns
|
||||
// validation and authentication of each retained Evidence item.
|
||||
const { comparisons } = z
|
||||
.strictObject({ comparisons: z.array(z.unknown()).min(1) })
|
||||
.parse(input);
|
||||
const evidence = comparisons.map(parseComparisonEvidence);
|
||||
assertUnique(
|
||||
evidence.map(({ evidence_id: id }) => id),
|
||||
evidence.map(({ evidence: { evidence_id: id } }) => id),
|
||||
"comparison Evidence",
|
||||
);
|
||||
const findings = evidence.flatMap(findingsFor).sort(compareFindings);
|
||||
const runtimeStatuses = evidence.flatMap(runtimeStatus);
|
||||
const links = uniqueSorted(
|
||||
evidence.flatMap((item) => [item.evidence_id, ...item.evidence_links]),
|
||||
const runtimeStatuses = evidence.flatMap((comparison) =>
|
||||
comparison.operation === "compare_process_captures"
|
||||
? [comparison.result.status]
|
||||
: [],
|
||||
);
|
||||
return changedBehaviorResultSchema.parse({
|
||||
const links = uniqueSorted(
|
||||
evidence.flatMap(({ evidence: item }) => [
|
||||
item.evidence_id,
|
||||
...item.evidence_links,
|
||||
]),
|
||||
);
|
||||
const result = changedBehaviorResultSchema.parse({
|
||||
behavior_status: behaviorStatus(runtimeStatuses),
|
||||
summary: {
|
||||
observed_changes: count(findings, "observed_change"),
|
||||
|
|
@ -147,7 +176,7 @@ export const findChangedBehavior = (
|
|||
},
|
||||
evidence_links: links,
|
||||
limitations: uniqueSorted([
|
||||
...evidence.flatMap(({ limitations }) => limitations),
|
||||
...evidence.flatMap(({ evidence: item }) => item.limitations),
|
||||
...(evidence.some(
|
||||
({ operation }) => operation !== "compare_process_captures",
|
||||
)
|
||||
|
|
@ -160,9 +189,15 @@ export const findChangedBehavior = (
|
|||
: []),
|
||||
]),
|
||||
});
|
||||
return {
|
||||
result,
|
||||
comparisonEvidenceIds: evidence.map(
|
||||
({ evidence: { evidence_id } }) => evidence_id,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const parseComparisonEvidence = (input: unknown): Evidence => {
|
||||
const parseComparisonEvidence = (input: unknown): ParsedComparison => {
|
||||
const evidence = parseEvidence(input);
|
||||
if (!(evidence.operation in EXPECTED_PROVIDERS))
|
||||
throw new TypeError(
|
||||
|
|
@ -198,12 +233,17 @@ const parseComparisonEvidence = (input: unknown): Evidence => {
|
|||
throw new TypeError(
|
||||
"Comparison Evidence must cite both selected source observations",
|
||||
);
|
||||
parseResult(evidence);
|
||||
return evidence;
|
||||
return parseResult(evidence, operation);
|
||||
};
|
||||
|
||||
const parseResult = (evidence: Evidence): void => {
|
||||
if (evidence.operation === "compare_process_captures") {
|
||||
const parseResult = (
|
||||
evidence: Evidence,
|
||||
operation:
|
||||
| "compare_process_captures"
|
||||
| "compare_artifacts"
|
||||
| "compare_functions",
|
||||
): ParsedComparison => {
|
||||
if (operation === "compare_process_captures") {
|
||||
const result = processCaptureComparisonSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
|
|
@ -214,9 +254,9 @@ const parseResult = (evidence: Evidence): void => {
|
|||
throw new TypeError(
|
||||
"Process comparison status contradicts its dimensions",
|
||||
);
|
||||
return;
|
||||
return { operation, evidence, result };
|
||||
}
|
||||
if (evidence.operation === "compare_functions") {
|
||||
if (operation === "compare_functions") {
|
||||
const result = functionComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
|
|
@ -224,7 +264,7 @@ const parseResult = (evidence: Evidence): void => {
|
|||
evidence,
|
||||
result.dimensions.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
return;
|
||||
return { operation, evidence, result };
|
||||
}
|
||||
const result = artifactComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
|
|
@ -233,6 +273,7 @@ const parseResult = (evidence: Evidence): void => {
|
|||
evidence,
|
||||
result.changes.flatMap(({ evidence_links: links }) => links),
|
||||
);
|
||||
return { operation, evidence, result };
|
||||
};
|
||||
|
||||
const assertNestedLinks = (
|
||||
|
|
@ -246,18 +287,21 @@ const assertNestedLinks = (
|
|||
);
|
||||
};
|
||||
|
||||
const findingsFor = (evidence: Evidence): Finding[] => {
|
||||
if (evidence.operation === "compare_process_captures")
|
||||
return processFindings(evidence);
|
||||
if (evidence.operation === "compare_functions")
|
||||
return functionFindings(evidence);
|
||||
return artifactFindings(evidence);
|
||||
const findingsFor = (comparison: ParsedComparison): Finding[] => {
|
||||
if (comparison.operation === "compare_process_captures")
|
||||
return processFindings(comparison);
|
||||
if (comparison.operation === "compare_functions")
|
||||
return functionFindings(comparison);
|
||||
return artifactFindings(comparison);
|
||||
};
|
||||
|
||||
const processFindings = (evidence: Evidence): Finding[] => {
|
||||
const result = processCaptureComparisonSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const processFindings = (
|
||||
comparison: Extract<
|
||||
ParsedComparison,
|
||||
{ readonly operation: "compare_process_captures" }
|
||||
>,
|
||||
): Finding[] => {
|
||||
const { evidence, result } = comparison;
|
||||
const dimensions = [
|
||||
["terminal", result.terminal, "runtime"],
|
||||
["interaction", result.interaction, "runtime"],
|
||||
|
|
@ -283,10 +327,13 @@ const processFindings = (evidence: Evidence): Finding[] => {
|
|||
);
|
||||
};
|
||||
|
||||
const functionFindings = (evidence: Evidence): Finding[] => {
|
||||
const result = functionComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const functionFindings = (
|
||||
comparison: Extract<
|
||||
ParsedComparison,
|
||||
{ readonly operation: "compare_functions" }
|
||||
>,
|
||||
): Finding[] => {
|
||||
const { evidence, result } = comparison;
|
||||
return result.dimensions.flatMap((dimension) =>
|
||||
dimension.status === "unchanged"
|
||||
? []
|
||||
|
|
@ -307,10 +354,13 @@ const functionFindings = (evidence: Evidence): Finding[] => {
|
|||
);
|
||||
};
|
||||
|
||||
const artifactFindings = (evidence: Evidence): Finding[] => {
|
||||
const result = artifactComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const artifactFindings = (
|
||||
comparison: Extract<
|
||||
ParsedComparison,
|
||||
{ readonly operation: "compare_artifacts" }
|
||||
>,
|
||||
): Finding[] => {
|
||||
const { evidence, result } = comparison;
|
||||
const changes = result.changes.map((change) =>
|
||||
makeFinding({
|
||||
evidence,
|
||||
|
|
@ -367,11 +417,6 @@ const makeFinding = (input: {
|
|||
limitations: uniqueSorted(input.limitations),
|
||||
});
|
||||
|
||||
const runtimeStatus = (evidence: Evidence): RuntimeStatus[] =>
|
||||
evidence.operation === "compare_process_captures"
|
||||
? [processCaptureComparisonSchema.parse(evidence.normalized_result).status]
|
||||
: [];
|
||||
|
||||
const behaviorStatus = (
|
||||
statuses: readonly RuntimeStatus[],
|
||||
): ChangedBehaviorResult["behavior_status"] => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
/** Whether TypeScript explicitly erases this entire import declaration. */
|
||||
export const isTypeOnlyImportDeclaration = (
|
||||
node: t.ImportDeclaration,
|
||||
): boolean => node.importKind === "type" || node.importKind === "typeof";
|
||||
|
||||
/** Whether TypeScript explicitly erases this entire export declaration. */
|
||||
export const isTypeOnlyExportDeclaration = (
|
||||
node: t.ExportNamedDeclaration | t.ExportAllDeclaration,
|
||||
): boolean =>
|
||||
node.exportKind === "type" ||
|
||||
(t.isExportNamedDeclaration(node) &&
|
||||
node.declaration !== null &&
|
||||
(t.isTSInterfaceDeclaration(node.declaration) ||
|
||||
t.isTSTypeAliasDeclaration(node.declaration) ||
|
||||
t.isTSDeclareFunction(node.declaration)));
|
||||
|
||||
/** Whether this import binding is explicitly type-only. */
|
||||
export const isTypeOnlyImportSpecifier = (node: t.ImportSpecifier): boolean =>
|
||||
node.importKind === "type" || node.importKind === "typeof";
|
||||
|
||||
/** Whether this exported binding is explicitly type-only. */
|
||||
export const isTypeOnlyExportSpecifier = (node: t.ExportSpecifier): boolean =>
|
||||
node.exportKind === "type";
|
||||
|
||||
/** A regular import whose bindings are all erased but may retain side effects. */
|
||||
export const hasOnlyInlineTypeImportSpecifiers = (
|
||||
node: t.ImportDeclaration,
|
||||
): boolean =>
|
||||
!isTypeOnlyImportDeclaration(node) &&
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(specifier) =>
|
||||
t.isImportSpecifier(specifier) && isTypeOnlyImportSpecifier(specifier),
|
||||
);
|
||||
|
||||
/** A regular re-export whose bindings are all erased but may retain side effects. */
|
||||
export const hasOnlyInlineTypeExportSpecifiers = (
|
||||
node: t.ExportNamedDeclaration,
|
||||
): boolean =>
|
||||
!isTypeOnlyExportDeclaration(node) &&
|
||||
node.source != null &&
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(specifier) =>
|
||||
t.isExportSpecifier(specifier) && isTypeOnlyExportSpecifier(specifier),
|
||||
);
|
||||
|
||||
/** Whether compiler options leave an inline type-only declaration's side effect unknown. */
|
||||
export const hasUncertainTypeOnlyModuleSideEffect = (
|
||||
program: t.Program,
|
||||
): boolean =>
|
||||
program.body.some(
|
||||
(node) =>
|
||||
(t.isImportDeclaration(node) &&
|
||||
hasOnlyInlineTypeImportSpecifiers(node)) ||
|
||||
(t.isExportNamedDeclaration(node) &&
|
||||
hasOnlyInlineTypeExportSpecifiers(node)),
|
||||
);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** Relative module syntax differs from an arbitrary dot-prefixed filename. */
|
||||
export const isRelativeJavaScriptModuleSpecifier = (value: string): boolean =>
|
||||
value === "." ||
|
||||
value === ".." ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../");
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { posix, relative, win32 } from "node:path";
|
||||
import { posix, win32 } from "node:path";
|
||||
|
||||
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
|
||||
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
|
||||
|
|
@ -216,7 +216,7 @@ const pathBelowRoot = (root: string, value: string): string | null => {
|
|||
const remainder =
|
||||
rootStyle === "windows"
|
||||
? win32.relative(root, value)
|
||||
: relative(root, value);
|
||||
: posix.relative(root, value);
|
||||
const portableRemainder = remainder.replaceAll("\\", "/");
|
||||
if (
|
||||
remainder === "" ||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ describe("TypeScript import-equals links", () => {
|
|||
localName: "dependency",
|
||||
exportedName: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "type-import",
|
||||
specifier: "./types",
|
||||
localName: "types",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -51,6 +56,57 @@ describe("TypeScript import-equals links", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("TypeScript type-only module links", () => {
|
||||
it("retains declarations without runtime bindings or callable claims", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
import type { Whole } from "./whole";
|
||||
import { type Inline } from "./inline";
|
||||
import { type Erased, value } from "./mixed";
|
||||
export type { Reexported } from "./reexported";
|
||||
export { type InlineExport } from "./inline-export";
|
||||
export type LocalType = string;
|
||||
export interface LocalInterface { value: string }
|
||||
function hiddenFunction() { return 1; }
|
||||
export type { hiddenFunction };
|
||||
`);
|
||||
|
||||
const externalLinks = ir.moduleLinks
|
||||
.filter(({ specifier }) => specifier !== null)
|
||||
.map(
|
||||
({ kind, specifier, importedName, exportedName }) =>
|
||||
[kind, specifier, importedName, exportedName] as const,
|
||||
);
|
||||
expect(externalLinks).toEqual([
|
||||
["type-import", "./whole", "Whole", null],
|
||||
["type-import", "./inline", "Inline", null],
|
||||
["type-import", "./mixed", "Erased", null],
|
||||
["import", "./mixed", "value", null],
|
||||
["type-import", "./reexported", "Reexported", "Reexported"],
|
||||
["type-import", "./inline-export", "InlineExport", "InlineExport"],
|
||||
]);
|
||||
|
||||
const typeLinks = ir.moduleLinks
|
||||
.filter(({ kind }) => kind === "type-import")
|
||||
.map(
|
||||
({ specifier, localName, exportedName, callableId }) =>
|
||||
[specifier, localName, exportedName, callableId] as const,
|
||||
);
|
||||
expect(typeLinks).toEqual([
|
||||
["./whole", "Whole", null, null],
|
||||
["./inline", "Inline", null, null],
|
||||
["./mixed", "Erased", null, null],
|
||||
["./reexported", null, "Reexported", null],
|
||||
["./inline-export", null, "InlineExport", null],
|
||||
[null, "LocalType", "LocalType", null],
|
||||
[null, "LocalInterface", "LocalInterface", null],
|
||||
[null, "hiddenFunction", "hiddenFunction", null],
|
||||
]);
|
||||
expect(ir.limitations).toContain(
|
||||
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommonJS dependency links", () => {
|
||||
it("retains an import-equals binding and its module origin", () => {
|
||||
const ir = analyzeJavaScriptSemantics(`
|
||||
|
|
@ -76,6 +132,12 @@ describe("CommonJS dependency links", () => {
|
|||
importedName: null,
|
||||
localName: "typed",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "type-import",
|
||||
specifier: "type-only",
|
||||
importedName: null,
|
||||
localName: "TypeOnly",
|
||||
}),
|
||||
]);
|
||||
expect(ir.references.filter(({ name }) => name === "typed")).toHaveLength(
|
||||
1,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js";
|
||||
import { hasUncertainTypeOnlyModuleSideEffect } from "./javascriptModuleDeclaration.js";
|
||||
import {
|
||||
collectSemanticReturns,
|
||||
resolveSemanticModuleCallables,
|
||||
|
|
@ -200,6 +201,11 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
|
|||
"The parser recovered from syntax errors; affected bindings are partial.",
|
||||
]
|
||||
: []),
|
||||
...(hasUncertainTypeOnlyModuleSideEffect(file.program)
|
||||
? [
|
||||
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
|
||||
]
|
||||
: []),
|
||||
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
|
||||
"Return sites include only direct callable returns; nested callable returns remain separate.",
|
||||
"Local call, argument, return, and closure relations are static candidates and do not prove runtime invocation.",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ import {
|
|||
propertyName,
|
||||
semanticStaticPropertyKey,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
isTypeOnlyExportDeclaration,
|
||||
isTypeOnlyExportSpecifier,
|
||||
isTypeOnlyImportDeclaration,
|
||||
isTypeOnlyImportSpecifier,
|
||||
} from "./javascriptModuleDeclaration.js";
|
||||
|
||||
interface CollectCallableInput {
|
||||
readonly node: t.Node;
|
||||
|
|
@ -75,7 +81,7 @@ export const collectSemanticModuleLink = (
|
|||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "re-export",
|
||||
kind: isTypeOnlyExportDeclaration(node) ? "type-import" : "re-export",
|
||||
specifier: node.source.value,
|
||||
importedName: "*",
|
||||
localName: null,
|
||||
|
|
@ -108,12 +114,19 @@ const collectImportEquals = (
|
|||
node: t.TSImportEqualsDeclaration,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const specifier = semanticImportEqualsSpecifier(node);
|
||||
const moduleReference = node.moduleReference;
|
||||
const specifier =
|
||||
semanticImportEqualsSpecifier(node) ??
|
||||
(node.importKind === "type" &&
|
||||
t.isTSExternalModuleReference(moduleReference) &&
|
||||
t.isStringLiteral(moduleReference.expression)
|
||||
? moduleReference.expression.value
|
||||
: null);
|
||||
if (specifier === null) return;
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "require",
|
||||
kind: node.importKind === "type" ? "type-import" : "require",
|
||||
specifier,
|
||||
importedName: null,
|
||||
localName: node.id.name,
|
||||
|
|
@ -287,7 +300,7 @@ const collectImports = (
|
|||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "import",
|
||||
kind: isTypeOnlyImportDeclaration(node) ? "type-import" : "import",
|
||||
specifier: node.source.value,
|
||||
importedName: null,
|
||||
localName: null,
|
||||
|
|
@ -300,7 +313,12 @@ const collectImports = (
|
|||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "import",
|
||||
kind:
|
||||
isTypeOnlyImportDeclaration(node) ||
|
||||
(t.isImportSpecifier(specifier) &&
|
||||
isTypeOnlyImportSpecifier(specifier))
|
||||
? "type-import"
|
||||
: "import",
|
||||
specifier: node.source.value,
|
||||
importedName: t.isImportDefaultSpecifier(specifier)
|
||||
? "default"
|
||||
|
|
@ -319,12 +337,13 @@ const collectNamedExports = (
|
|||
node: t.ExportNamedDeclaration,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const declarationTypeOnly = isTypeOnlyExportDeclaration(node);
|
||||
if (node.declaration !== null && node.declaration !== undefined)
|
||||
for (const localName of declarationNames(node.declaration))
|
||||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: "export",
|
||||
kind: declarationTypeOnly ? "type-import" : "export",
|
||||
specifier: null,
|
||||
importedName: null,
|
||||
localName,
|
||||
|
|
@ -342,7 +361,14 @@ const collectNamedExports = (
|
|||
addModuleLink(
|
||||
state,
|
||||
{
|
||||
kind: node.source == null ? "export" : "re-export",
|
||||
kind:
|
||||
declarationTypeOnly ||
|
||||
(t.isExportSpecifier(specifier) &&
|
||||
isTypeOnlyExportSpecifier(specifier))
|
||||
? "type-import"
|
||||
: node.source == null
|
||||
? "export"
|
||||
: "re-export",
|
||||
specifier: node.source?.value ?? null,
|
||||
importedName: t.isExportNamespaceSpecifier(specifier)
|
||||
? "*"
|
||||
|
|
@ -745,6 +771,13 @@ const declarationNames = (declaration: t.Declaration): string[] => {
|
|||
t.isIdentifier(declaration.id)
|
||||
)
|
||||
return [declaration.id.name];
|
||||
if (
|
||||
(t.isTSInterfaceDeclaration(declaration) ||
|
||||
t.isTSTypeAliasDeclaration(declaration) ||
|
||||
t.isTSDeclareFunction(declaration)) &&
|
||||
t.isIdentifier(declaration.id)
|
||||
)
|
||||
return [declaration.id.name];
|
||||
if (
|
||||
t.isTSImportEqualsDeclaration(declaration) &&
|
||||
semanticImportEqualsSpecifier(declaration) !== null
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const resolveSemanticModuleCallables = (
|
|||
bindings.map(({ bindingId, provenance }) => [bindingId, provenance]),
|
||||
);
|
||||
return state.moduleLinks.map((link) => {
|
||||
if (link.kind === "type-import") return { ...link, callableId: null };
|
||||
// Named exports follow their lexical binding, including every assignment.
|
||||
// A declaration's ID or a callable's display name cannot prove that link.
|
||||
if (link.localName === null)
|
||||
|
|
|
|||
|
|
@ -37,10 +37,19 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => {
|
|||
: ["decorators-legacy", typescript];
|
||||
};
|
||||
|
||||
/** `.mjs` and `.mts` are modules by extension; other paths leave the mode to syntax. */
|
||||
/** `.mjs` and `.mts` explicitly select module mode. */
|
||||
export const isExplicitModuleSourcePath = (path?: string): boolean =>
|
||||
/\.m[jt]s$/iu.test(path ?? "");
|
||||
|
||||
const runtimeSourceTypeForPath = (
|
||||
path?: string,
|
||||
): "commonjs" | "module" | "unambiguous" =>
|
||||
/\.cjs$/iu.test(path ?? "")
|
||||
? "commonjs"
|
||||
: isExplicitModuleSourcePath(path)
|
||||
? "module"
|
||||
: "unambiguous";
|
||||
|
||||
export interface RecoveredJavaScriptParse {
|
||||
readonly file: ParsedJavaScriptSource;
|
||||
/** Errors that apply only if a source of unknown mode is an ES module. */
|
||||
|
|
@ -58,25 +67,25 @@ export const parseRecoveredJavaScript = (
|
|||
sourcePath: string | undefined,
|
||||
options: Omit<ParserOptions, "sourceType" | "errorRecovery">,
|
||||
): RecoveredJavaScriptParse => {
|
||||
// `.cjs` is CommonJS by extension, so module-only errors cannot apply.
|
||||
if (/\.cjs$/iu.test(sourcePath ?? ""))
|
||||
const sourceType = runtimeSourceTypeForPath(sourcePath);
|
||||
if (sourceType !== "unambiguous") {
|
||||
const normalizedOptions = { ...options };
|
||||
if (sourceType === "commonjs") {
|
||||
// Babel owns these wrapper semantics in CommonJS mode and rejects
|
||||
// explicit script overrides, including the reference-import options.
|
||||
delete normalizedOptions.allowAwaitOutsideFunction;
|
||||
delete normalizedOptions.allowReturnOutsideFunction;
|
||||
delete normalizedOptions.allowNewTargetOutsideFunction;
|
||||
}
|
||||
return {
|
||||
file: parse(source, {
|
||||
...options,
|
||||
sourceType: "script",
|
||||
errorRecovery: true,
|
||||
}),
|
||||
moduleOnlyErrors: [],
|
||||
};
|
||||
if (isExplicitModuleSourcePath(sourcePath))
|
||||
return {
|
||||
file: parse(source, {
|
||||
...options,
|
||||
sourceType: "module",
|
||||
...normalizedOptions,
|
||||
sourceType,
|
||||
errorRecovery: true,
|
||||
}),
|
||||
moduleOnlyErrors: [],
|
||||
};
|
||||
}
|
||||
const file = parse(source, {
|
||||
...options,
|
||||
sourceType: "unambiguous",
|
||||
|
|
@ -98,18 +107,34 @@ export const parseRecoveredJavaScript = (
|
|||
};
|
||||
};
|
||||
|
||||
/** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */
|
||||
/** Parse inert JavaScript or TypeScript facts without attaching comments to AST nodes. */
|
||||
export const parseJavaScriptSource = (
|
||||
source: string,
|
||||
sourcePath?: string,
|
||||
): ParsedJavaScriptSource | null => {
|
||||
try {
|
||||
return parse(source, {
|
||||
sourceType: "unambiguous",
|
||||
const runtimeSourceType = runtimeSourceTypeForPath(sourcePath);
|
||||
const commonjs = runtimeSourceType === "commonjs";
|
||||
const options = {
|
||||
errorRecovery: true,
|
||||
attachComment: false,
|
||||
plugins: parserPluginsForPath(sourcePath),
|
||||
};
|
||||
const file = parse(source, {
|
||||
...options,
|
||||
// Inert artifact analysis retains module declarations even in .cjs assets.
|
||||
// Infer their syntax here; reference imports keep filename-mode diagnostics.
|
||||
sourceType: commonjs ? "unambiguous" : runtimeSourceType,
|
||||
allowReturnOutsideFunction: commonjs,
|
||||
allowNewTargetOutsideFunction: commonjs,
|
||||
});
|
||||
// Unambiguous mode retains module-only diagnostics after choosing a script.
|
||||
// The .cjs extension establishes wrapper mode, so obtain its own diagnostics.
|
||||
return commonjs &&
|
||||
file.program.sourceType === "script" &&
|
||||
file.errors.length > 0
|
||||
? parse(source, { ...options, sourceType: "commonjs" })
|
||||
: file;
|
||||
} catch (cause: unknown) {
|
||||
// Unparseable source is represented by the null return.
|
||||
void cause;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ import {
|
|||
|
||||
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
|
||||
import { semanticImportEqualsSpecifier } from "./javascriptSemanticProjection.js";
|
||||
import {
|
||||
hasUncertainTypeOnlyModuleSideEffect,
|
||||
isTypeOnlyExportDeclaration,
|
||||
isTypeOnlyImportDeclaration,
|
||||
} from "./javascriptModuleDeclaration.js";
|
||||
|
||||
/** Parse one JavaScript artifact and recover static structure only. */
|
||||
export const analyzeJavaScriptStaticSource = (
|
||||
|
|
@ -154,6 +159,11 @@ const finalizeStaticAnalysis = (
|
|||
: [
|
||||
"One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.",
|
||||
]),
|
||||
...(hasUncertainTypeOnlyModuleSideEffect(file.program)
|
||||
? [
|
||||
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
|
||||
]
|
||||
: []),
|
||||
...(accumulator.references.some(
|
||||
({ value }) =>
|
||||
value.specifier !== null && ELECTRON_MODULE.test(value.specifier),
|
||||
|
|
@ -246,14 +256,20 @@ const inspectNode = (
|
|||
addReference(findings, { node, kind: "require", specifier });
|
||||
} else if (
|
||||
(t.isImportDeclaration(node) || t.isExportAllDeclaration(node)) &&
|
||||
node.source !== undefined
|
||||
node.source !== undefined &&
|
||||
!(t.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) &&
|
||||
!(t.isExportAllDeclaration(node) && isTypeOnlyExportDeclaration(node))
|
||||
)
|
||||
addReference(findings, {
|
||||
node,
|
||||
kind: "static-import",
|
||||
specifier: node.source.value,
|
||||
});
|
||||
else if (t.isExportNamedDeclaration(node) && node.source != null)
|
||||
else if (
|
||||
t.isExportNamedDeclaration(node) &&
|
||||
node.source != null &&
|
||||
!isTypeOnlyExportDeclaration(node)
|
||||
)
|
||||
addReference(findings, {
|
||||
node,
|
||||
kind: "static-import",
|
||||
|
|
|
|||
|
|
@ -33,3 +33,24 @@ it("rejects incomplete headers, invalid signatures and zero dimensions", () => {
|
|||
expect(readPngHeaderDimensions(zero)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0, 12, 14, 0xffff_ffff])("rejects IHDR data length %s", (length) => {
|
||||
const bytes = Buffer.from(png);
|
||||
bytes.writeUInt32BE(length, 8);
|
||||
expect(readPngHeaderDimensions(bytes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([16, 20])("rejects out-of-range PNG dimension at %s", (offset) => {
|
||||
const bytes = Buffer.from(png);
|
||||
bytes.writeUInt32BE(0x8000_0000, offset);
|
||||
expect(readPngHeaderDimensions(bytes)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts the largest dimension representable by the PNG specification", () => {
|
||||
const bytes = Buffer.from(png);
|
||||
bytes.writeUInt32BE(0x7fff_ffff, 16);
|
||||
expect(readPngHeaderDimensions(bytes)).toEqual({
|
||||
width: 0x7fff_ffff,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ export const readPngHeaderDimensions = (
|
|||
if (
|
||||
bytes.byteLength < 24 ||
|
||||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
|
||||
bytes.readUInt32BE(8) !== 13 ||
|
||||
!bytes.subarray(12, 16).equals(IHDR_TYPE)
|
||||
)
|
||||
return undefined;
|
||||
const width = bytes.readUInt32BE(16);
|
||||
const height = bytes.readUInt32BE(20);
|
||||
return width === 0 || height === 0 ? undefined : { width, height };
|
||||
return width === 0 ||
|
||||
height === 0 ||
|
||||
width > 0x7fff_ffff ||
|
||||
height > 0x7fff_ffff
|
||||
? undefined
|
||||
: { width, height };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ const validateCommitments = (
|
|||
digestProcessCommitment(
|
||||
value,
|
||||
), `manifest.${field}`, "commitment does not match its canonical value");
|
||||
require(Date.parse(manifest.started_at) <=
|
||||
Date.parse(
|
||||
manifest.completed_at,
|
||||
), "manifest.completed_at", "completion precedes start");
|
||||
};
|
||||
|
||||
const validateOrdering = (
|
||||
|
|
|
|||
|
|
@ -128,6 +128,9 @@ export const processComparisonContract = (
|
|||
: {}),
|
||||
limits: scenario.limits,
|
||||
normalization: scenario.normalization,
|
||||
...(scenario.normalization.paths
|
||||
? { path_normalization_version: "longest-original-paths-v1" }
|
||||
: {}),
|
||||
...(scenario.normalization.ports
|
||||
? { port_normalization_version: "contextual-endpoints-v2" }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseProcessCapture } from "./processCaptureParsing.js";
|
||||
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
|
||||
import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js";
|
||||
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
|
||||
import {
|
||||
capture,
|
||||
|
|
@ -100,6 +101,33 @@ describe("generic capture trace comparison", () => {
|
|||
right: { matched_variant: "two" },
|
||||
});
|
||||
});
|
||||
|
||||
it("compares a declared identifier the comparison result can represent", () => {
|
||||
// The specification owns the trace identifier vocabulary and the result
|
||||
// echoes those identifiers verbatim, so both must accept the same values
|
||||
// rather than handing the caller a raw schema error for its own input.
|
||||
const eventId = `e${"0".repeat(64)}`;
|
||||
const specification: ProcessTraceSpecification = {
|
||||
events: [
|
||||
{
|
||||
id: eventId,
|
||||
source: "terminal_raw",
|
||||
exact: terminal,
|
||||
cardinality: { kind: "required" },
|
||||
},
|
||||
],
|
||||
language: {
|
||||
kind: "finite_traces",
|
||||
variants: [{ id: "only", trace: [eventId] }],
|
||||
},
|
||||
};
|
||||
expect(specificationSchema.safeParse(specification).success).toBe(true);
|
||||
const left = capture(values(["terminal", "process"]));
|
||||
expect(compareProcessTraces(left, left, specification)).toMatchObject({
|
||||
verdict: "equivalent",
|
||||
left: { matched_variant: "only", raw_trace: [{ event_id: eventId }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("process trace constraints and evidence sufficiency", () => {
|
||||
|
|
|
|||
|
|
@ -13,13 +13,12 @@ import {
|
|||
import {
|
||||
canonicalTraceJson,
|
||||
processTraceCardinalityBounds,
|
||||
processTraceIdentifierSchema,
|
||||
type ProcessTraceSpecification,
|
||||
} from "./processTraceSpecification.js";
|
||||
|
||||
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]{0,63}$/u);
|
||||
|
||||
const matchedEventSchema = z.strictObject({
|
||||
event_id: identifierSchema,
|
||||
event_id: processTraceIdentifierSchema,
|
||||
location: processObservationLocationSchema,
|
||||
});
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ const sideResultSchema = z.union([
|
|||
z.strictObject({
|
||||
...sideResultShape,
|
||||
status: z.literal("pass"),
|
||||
matched_variant: identifierSchema.nullable(),
|
||||
matched_variant: processTraceIdentifierSchema.nullable(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...sideResultShape,
|
||||
|
|
@ -53,7 +52,7 @@ const diagnosticSchema = z.strictObject({
|
|||
]),
|
||||
side: z.enum(["left", "right"]),
|
||||
message: z.string(),
|
||||
event_ids: z.array(identifierSchema),
|
||||
event_ids: z.array(processTraceIdentifierSchema),
|
||||
locations: z.array(processObservationLocationSchema),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,16 @@ import {
|
|||
processObservationSourceSchema,
|
||||
} from "./processObservation.js";
|
||||
|
||||
const identifierSchema = z.string().regex(IDENTIFIER_PATTERN);
|
||||
/**
|
||||
* A declared trace, event or variant identifier. The specification owns this
|
||||
* vocabulary and the comparison result echoes those identifiers verbatim, so
|
||||
* the result module must reuse this schema rather than restate the pattern:
|
||||
* two bounds on the same identifier would reject the result of an accepted
|
||||
* specification.
|
||||
*/
|
||||
export const processTraceIdentifierSchema = z
|
||||
.string()
|
||||
.regex(IDENTIFIER_PATTERN);
|
||||
|
||||
const cardinalitySchema = z.discriminatedUnion("kind", [
|
||||
z.strictObject({ kind: z.literal("required") }),
|
||||
|
|
@ -31,7 +40,7 @@ const cardinalitySchema = z.discriminatedUnion("kind", [
|
|||
]);
|
||||
|
||||
const eventDeclarationSchema = z.strictObject({
|
||||
id: identifierSchema,
|
||||
id: processTraceIdentifierSchema,
|
||||
source: processObservationSourceSchema,
|
||||
exact: jsonValueSchema,
|
||||
ignore_fields: z
|
||||
|
|
@ -56,29 +65,31 @@ const partialOrderLanguageSchema = z.strictObject({
|
|||
happens_before: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
before: identifierSchema,
|
||||
after: identifierSchema,
|
||||
before: processTraceIdentifierSchema,
|
||||
after: processTraceIdentifierSchema,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
not_before: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
event: identifierSchema,
|
||||
anchor: identifierSchema,
|
||||
event: processTraceIdentifierSchema,
|
||||
anchor: processTraceIdentifierSchema,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
unordered_groups: z
|
||||
.array(z.strictObject({ events: z.array(identifierSchema).min(2) }))
|
||||
.array(
|
||||
z.strictObject({ events: z.array(processTraceIdentifierSchema).min(2) }),
|
||||
)
|
||||
.default([]),
|
||||
prefix: z.array(identifierSchema).default([]),
|
||||
suffix: z.array(identifierSchema).default([]),
|
||||
prefix: z.array(processTraceIdentifierSchema).default([]),
|
||||
suffix: z.array(processTraceIdentifierSchema).default([]),
|
||||
});
|
||||
|
||||
const finiteTraceTokenSchema = z.union([
|
||||
identifierSchema,
|
||||
z.strictObject({ unordered: z.array(identifierSchema).min(2) }),
|
||||
processTraceIdentifierSchema,
|
||||
z.strictObject({ unordered: z.array(processTraceIdentifierSchema).min(2) }),
|
||||
]);
|
||||
|
||||
const finiteTraceLanguageSchema = z.strictObject({
|
||||
|
|
@ -86,7 +97,7 @@ const finiteTraceLanguageSchema = z.strictObject({
|
|||
variants: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: identifierSchema,
|
||||
id: processTraceIdentifierSchema,
|
||||
trace: z.array(finiteTraceTokenSchema),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
parserPluginsForPath,
|
||||
} from "./javascript/javascriptSourceParser.js";
|
||||
import { unshadowedReferenceSourceRequireCalls } from "./referenceSourceRequireBindings.js";
|
||||
import { isRelativeJavaScriptModuleSpecifier } from "./javascript/javascriptModuleSpecifier.js";
|
||||
import {
|
||||
isCallExpression,
|
||||
isExportAllDeclaration,
|
||||
|
|
@ -60,7 +61,8 @@ export interface ReferenceSourceImportParseResult {
|
|||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const codeSourceLanguages = new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
||||
export const PARSEABLE_REFERENCE_SOURCE_LANGUAGES: ReadonlySet<string> =
|
||||
new Set(["JavaScript", "TypeScript", "JSX", "TSX"]);
|
||||
|
||||
const safeModuleName = (node: Node | null | undefined): string | undefined => {
|
||||
if (isStringLiteral(node)) return node.value;
|
||||
|
|
@ -70,9 +72,11 @@ const safeModuleName = (node: Node | null | undefined): string | undefined => {
|
|||
const resolveSpecifier = (
|
||||
specifier: string,
|
||||
): ReferenceSourceImportResolution => {
|
||||
if (specifier.startsWith(".") || specifier.startsWith("/")) return "internal";
|
||||
if (specifier.startsWith("node:") || specifier.startsWith("data:"))
|
||||
return "external";
|
||||
if (
|
||||
isRelativeJavaScriptModuleSpecifier(specifier) ||
|
||||
specifier.startsWith("/")
|
||||
)
|
||||
return "internal";
|
||||
return "external";
|
||||
};
|
||||
|
||||
|
|
@ -314,7 +318,7 @@ export const parseReferenceSourceImports = (
|
|||
bytes: Uint8Array,
|
||||
language: string | null,
|
||||
): ReferenceSourceImportParseResult => {
|
||||
if (language !== null && !codeSourceLanguages.has(language))
|
||||
if (language !== null && !PARSEABLE_REFERENCE_SOURCE_LANGUAGES.has(language))
|
||||
return { relationships: [], parse_failures: [], limitations: [] };
|
||||
|
||||
let source: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { z } from "zod";
|
||||
import { signalRecordingSchema } from "./signalRecording.js";
|
||||
|
||||
export const inspectSignalDecodersInputSchema = z.strictObject({
|
||||
decoder: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_+\x2d]+$/u)
|
||||
.max(256)
|
||||
.nullable()
|
||||
.default(null)
|
||||
.describe(
|
||||
"Exact installed decoder ID for help; null searches the decoder catalog",
|
||||
),
|
||||
query: z
|
||||
.string()
|
||||
.max(8192)
|
||||
.default("")
|
||||
.describe(
|
||||
"Case-insensitive literal substring in decoder ID/name or help lines; empty selects all",
|
||||
),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Number of matching decoders or help lines to skip; defaults to 0",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(Number.MAX_SAFE_INTEGER)
|
||||
.nullable()
|
||||
.default(50)
|
||||
.describe(
|
||||
"Maximum matching decoders or help lines returned; null requests all remaining",
|
||||
),
|
||||
});
|
||||
export type InspectSignalDecodersInput = z.infer<
|
||||
typeof inspectSignalDecodersInputSchema
|
||||
>;
|
||||
export const signalDecodersSchema = z
|
||||
.strictObject({
|
||||
selection: inspectSignalDecodersInputSchema,
|
||||
decoders: z.array(z.strictObject({ id: z.string(), name: z.string() })),
|
||||
help_lines: z.array(
|
||||
z.strictObject({
|
||||
line_number: z.number().int().positive(),
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
output_window: z.strictObject({
|
||||
unit: z.enum(["decoders", "help-lines"]),
|
||||
examined: z.number().int().nonnegative(),
|
||||
matching: z.number().int().nonnegative(),
|
||||
returned: z.number().int().nonnegative(),
|
||||
has_more: z.boolean(),
|
||||
}),
|
||||
diagnostics: z.string(),
|
||||
tool: signalRecordingSchema.shape.tool,
|
||||
resource_limiter: signalRecordingSchema.shape.resource_limiter,
|
||||
limits: signalRecordingSchema.shape.limits,
|
||||
hardware_acquisition: z.literal("not-performed"),
|
||||
})
|
||||
.superRefine((report, context) => {
|
||||
const w = report.output_window;
|
||||
const catalog = report.selection.decoder === null;
|
||||
const remaining = Math.max(0, w.matching - report.selection.offset);
|
||||
const expected = Math.min(remaining, report.selection.limit ?? remaining);
|
||||
if (
|
||||
w.unit !== (catalog ? "decoders" : "help-lines") ||
|
||||
w.matching > w.examined ||
|
||||
w.returned !== expected ||
|
||||
(catalog ? report.decoders : report.help_lines).length !== expected ||
|
||||
(catalog ? report.help_lines : report.decoders).length !== 0 ||
|
||||
w.has_more !== remaining > expected
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["output_window"],
|
||||
message:
|
||||
"Discovery coverage must match the selected page and result kind",
|
||||
});
|
||||
});
|
||||
|
|
@ -25,11 +25,18 @@ describe("native module syntax observations", () => {
|
|||
item.expression,
|
||||
);
|
||||
});
|
||||
it("excludes erased TypeScript imports/exports and bundler inference", () => {
|
||||
it("excludes whole type declarations and retains inline type dependency syntax", () => {
|
||||
const result = collectWebModuleImports(
|
||||
'import type {T} from "types"; import {type X} from "also-types"; export type {T} from "export-types"; export {type Y} from "only-types"; import {type T, value} from "mixed"; __webpack_require__.e(4); __vitePreload(() => 1, ["chunk.js"]);',
|
||||
);
|
||||
expect(result.imports.map((item) => item.specifier)).toEqual(["mixed"]);
|
||||
expect(result.imports.map((item) => item.specifier)).toEqual([
|
||||
"also-types",
|
||||
"only-types",
|
||||
"mixed",
|
||||
]);
|
||||
expect(result.diagnostics).toContain(
|
||||
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
|
||||
);
|
||||
});
|
||||
it("uses UTF-16 source locations after astral characters", () => {
|
||||
const source = 'const emoji = "😀";\nimport "./x.js";';
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@ import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.
|
|||
import { parseJavaScriptSource } from "./javascript/javascriptSourceParser.js";
|
||||
import type { WebModuleImport } from "./webModuleTrace.js";
|
||||
import type { ExportedWebScript } from "./webScriptExport.js";
|
||||
import {
|
||||
hasUncertainTypeOnlyModuleSideEffect,
|
||||
isTypeOnlyExportDeclaration,
|
||||
isTypeOnlyImportDeclaration,
|
||||
} from "./javascript/javascriptModuleDeclaration.js";
|
||||
|
||||
/** Collect native import syntax, excluding erased type-only references. */
|
||||
/** Collect module declarations, omitting whole-statement type-only forms. */
|
||||
export const collectWebModuleImports = (
|
||||
source: string,
|
||||
): {
|
||||
|
|
@ -55,35 +60,18 @@ export const collectWebModuleImports = (
|
|||
traverseJavaScriptAst(ast, {
|
||||
enter: (node) => {
|
||||
if (node.type === "ImportDeclaration") {
|
||||
if (node.importKind === "type" || node.importKind === "typeof") return;
|
||||
if (
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(s) =>
|
||||
s.type === "ImportSpecifier" &&
|
||||
(s.importKind === "type" || s.importKind === "typeof"),
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (isTypeOnlyImportDeclaration(node)) return;
|
||||
add("static-import", node.source);
|
||||
} else if (
|
||||
node.type === "ExportAllDeclaration" ||
|
||||
node.type === "ExportNamedDeclaration"
|
||||
) {
|
||||
if (
|
||||
node.exportKind === "type" ||
|
||||
isTypeOnlyExportDeclaration(node) ||
|
||||
node.source === null ||
|
||||
node.source === undefined
|
||||
)
|
||||
return;
|
||||
if (
|
||||
node.type === "ExportNamedDeclaration" &&
|
||||
node.specifiers.length > 0 &&
|
||||
node.specifiers.every(
|
||||
(s) => s.type === "ExportSpecifier" && s.exportKind === "type",
|
||||
)
|
||||
)
|
||||
return;
|
||||
add("re-export", node.source);
|
||||
} else if (node.type === "ImportExpression")
|
||||
add("dynamic-import", node.source);
|
||||
|
|
@ -99,7 +87,14 @@ export const collectWebModuleImports = (
|
|||
return {
|
||||
imports,
|
||||
state: "parsed",
|
||||
diagnostics: ast.errors?.map((error) => error.message) ?? [],
|
||||
diagnostics: [
|
||||
...(ast.errors?.map((error) => error.message) ?? []),
|
||||
...(hasUncertainTypeOnlyModuleSideEffect(ast.program)
|
||||
? [
|
||||
"Compiler options are unavailable; inline type-only imports or re-exports may preserve a module side-effect dependency.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue