Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64228f8144 | ||
|
|
576abe196f | ||
|
|
b2d2e43068 | ||
|
|
acae8a5382 | ||
|
|
1deb6e87bf | ||
|
|
fd3d29bc8d | ||
|
|
27faf8b983 | ||
|
|
3a1aa3fd03 | ||
|
|
3b272dc8e2 | ||
|
|
5bea037552 | ||
|
|
2c36f8d8e5 | ||
|
|
054be92874 | ||
|
|
67f4b79b32 | ||
|
|
b01db67586 | ||
|
|
3c4bd35180 | ||
|
|
b647e4a4e8 | ||
|
|
7f2f0b78c4 | ||
|
|
61141032f7 | ||
|
|
303dc2aa73 | ||
|
|
98c3c527d8 | ||
|
|
5f986d4869 | ||
|
|
b9539e9c1c | ||
|
|
92e17ccfce | ||
|
|
9392590f2f | ||
|
|
ef0c8005bc |
No files matched your search
@@ -741,6 +741,7 @@ jobs:
|
|||||||
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts
|
||||||
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
tests/boundary/filesystem/webScriptManifestPaths.test.ts
|
||||||
tests/boundary/process/ProcessEvidence.host.test.ts
|
tests/boundary/process/ProcessEvidence.host.test.ts
|
||||||
|
tests/boundary/process/windowsOutputCapture.test.ts
|
||||||
tests/acceptance/webModuleCli.test.ts
|
tests/acceptance/webModuleCli.test.ts
|
||||||
tests/acceptance/webSourceLocationCli.test.ts
|
tests/acceptance/webSourceLocationCli.test.ts
|
||||||
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
tests/boundary/cli/referenceSourceImportStatus.test.ts
|
||||||
|
|||||||
@@ -1340,7 +1340,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
|
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
|
||||||
while (incomingIterator.hasNext()) {
|
while (incomingIterator.hasNext()) {
|
||||||
monitor.checkCancelled();
|
monitor.checkCancelled();
|
||||||
addReference(incoming, incomingIterator.next());
|
Reference reference = incomingIterator.next();
|
||||||
|
if (!reference.isEntryPointReference()) addReference(incoming, reference);
|
||||||
}
|
}
|
||||||
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
|
List<Reference> orderedIncoming = new ArrayList<>(incoming.values());
|
||||||
orderedIncoming.sort(REFERENCE_ORDER);
|
orderedIncoming.sort(REFERENCE_ORDER);
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ that rejection does not roll back browser effects. REA removes its owned
|
|||||||
future-document scripts, restores live frame methods, then disconnects.
|
future-document scripts, restores live frame methods, then disconnects.
|
||||||
Add secret declarations, storage seeds, and additional artifact or event
|
Add secret declarations, storage seeds, and additional artifact or event
|
||||||
capture only when needed. Action, secret, and storage counts are not capped.
|
capture only when needed. Action, secret, and storage counts are not capped.
|
||||||
Duration, action, and navigation
|
Duration, action, and navigation timeouts remain fixed provider-owned liveness
|
||||||
timeouts remain fixed provider-owned liveness limits; the request does not
|
limits. `capture.limits` controls retained event and network-content budgets;
|
||||||
accept a caller-controlled `limits` object.
|
it does not change those liveness deadlines.
|
||||||
|
|
||||||
## Minimal launch example
|
## Minimal launch example
|
||||||
|
|
||||||
@@ -179,12 +179,44 @@ declared action. Each step reports action status, elapsed time, sanitized URLs,
|
|||||||
event bounds, and independently typed capture states for screenshot, DOM,
|
event bounds, and independently typed capture states for screenshot, DOM,
|
||||||
accessibility, URL, history, and storage. Console, page-error, network,
|
accessibility, URL, history, and storage. Console, page-error, network,
|
||||||
WebSocket, frame, worker, popup, and cancelled-download events are attributed to
|
WebSocket, frame, worker, popup, and cancelled-download events are attributed to
|
||||||
steps and all observed events and requested artifacts are returned inline.
|
steps. Retained events and requested artifacts are returned inline; omitted
|
||||||
There are no event, frame, worker, popup, WebSocket, DOM-node,
|
events are counted by kind and identify affected network transactions.
|
||||||
accessibility-node, storage-entry, screenshot-count, or cumulative
|
Event and selected network-content retention have caller-selected limits under
|
||||||
metadata-byte caps. Requested artifacts and observed events are returned
|
`capture.limits`. Omitted values use the defaults below; every supplied value
|
||||||
inline without application-defined size ceilings. Other missing sections
|
must be a positive safe integer, and callers can raise or lower a default.
|
||||||
remain explicit and make the capture ineligible for equality claims.
|
Byte limits are conservative allocation-cost estimates for retained event
|
||||||
|
values, including UTF-16 storage and JSON expansion, rather than serialized
|
||||||
|
bytes or a promise about exact process memory. `events.omissions.by_kind`
|
||||||
|
reports counts and reasons for omitted event kinds. Its
|
||||||
|
`network_transactions` rows identify affected transaction IDs and omitted
|
||||||
|
event kinds; `network_transaction_range` identifies the contiguous IDs beyond
|
||||||
|
the transaction limit. Omission metadata is bounded by the selected
|
||||||
|
transaction limit. Other missing sections remain explicit and make the capture
|
||||||
|
ineligible for equality claims.
|
||||||
|
|
||||||
|
The default limits are 10,000 events, 4 MiB estimated allocation for one
|
||||||
|
event, 16 MiB estimated allocation across retained events, 1,024 network
|
||||||
|
transactions, 64 KiB of body bytes per transaction phase, 1 MiB estimated
|
||||||
|
allocation for body redaction/base64, 1 MiB for retained header values, and
|
||||||
|
1 MiB for network metadata. The same limits are included in the capture
|
||||||
|
Evidence. Playwright materializes response bodies before REA can inspect their
|
||||||
|
byte length; `max_network_body_bytes` bounds subsequent retained copies and
|
||||||
|
does not bound the browser API read itself. A body that exceeds its selected
|
||||||
|
limit has an explicit `body-size-limit` or `body-allocation-limit` unavailable
|
||||||
|
state with the observed and allowed values.
|
||||||
|
|
||||||
|
Additional implementation bounds are 16 simultaneous network-content reads,
|
||||||
|
a 5-second deadline for each provider API read, and 2,048 live event-listener
|
||||||
|
owners. These are not caller-selectable. The concurrency bound queues admitted
|
||||||
|
content jobs and does not itself omit observations; a provider read timeout
|
||||||
|
produces a typed unavailable state, while exceeding listener capacity produces
|
||||||
|
event-kind omission counts. A preflight avoids body redaction and base64 copies
|
||||||
|
when the currently available event budget already rejects the body.
|
||||||
|
Concurrent reads may each pass that check before their events are retained;
|
||||||
|
their retained-copy work is bounded by 16 times the selected per-body
|
||||||
|
allocation limit. The Playwright response Buffer itself is materialized before
|
||||||
|
REA can apply that limit. Requested artifacts and retained events are returned
|
||||||
|
inline within the declared limits.
|
||||||
Attach-mode captures also declare the unavoidable pre-attach event gap.
|
Attach-mode captures also declare the unavoidable pre-attach event gap.
|
||||||
|
|
||||||
Selected event families are observed on the root page and discovered descendant
|
Selected event families are observed on the root page and discovered descendant
|
||||||
@@ -288,16 +320,21 @@ retained representation after redaction.
|
|||||||
|
|
||||||
Only completed responses are read. At the end of the final step, observation
|
Only completed responses are read. At the end of the final step, observation
|
||||||
stops; requests still in flight emit `request-unfinished`, and their observed
|
stops; requests still in flight emit `request-unfinished`, and their observed
|
||||||
responses have explicitly unavailable bodies. This cutoff does not wait for
|
responses have explicitly unavailable bodies. Already-admitted content reads
|
||||||
long-lived streaming connections. Already-started content reads settle before
|
drain before browser cleanup. At most 16 reads run at once; queued work is
|
||||||
browser cleanup, with a provider-owned five-second timeout per read and
|
bounded by the selected network-transaction limit and by successful admission
|
||||||
cancellation support. Failed or unexposed reads retain per-content states and make selected
|
of its request or response event. Each provider API read has its own five-second
|
||||||
event coverage incomplete. Body events can arrive during later steps or final
|
timeout once it starts, so queue wait adds to total drain and cleanup time; this
|
||||||
drain; event bounds describe receipt during the step, while the source event
|
is not a five-second total capture cutoff. Cancellation interrupts active reads
|
||||||
reference establishes the transaction phase. Comparison attributes content, like
|
and queued jobs produce cancelled states without calling their provider APIs.
|
||||||
every other event of its transaction, to the step that sent the request (the
|
This drain does not wait for long-lived unfinished streaming connections and
|
||||||
receipt step when that request was not observed) and excludes the sequence
|
does not refetch unfinished response bodies. Failed or unexposed reads retain
|
||||||
reference from normalization.
|
per-content states and make selected event coverage incomplete. Body events can
|
||||||
|
arrive during later steps or final drain; event bounds describe receipt during
|
||||||
|
the step, while the source event reference establishes the transaction phase.
|
||||||
|
Comparison attributes content, like every other event of its transaction, to
|
||||||
|
the step that sent the request (the receipt step when that request was not
|
||||||
|
observed) and excludes the sequence reference from normalization.
|
||||||
Playwright scenario capture does not supply initiator stacks; passive CDP
|
Playwright scenario capture does not supply initiator stacks; passive CDP
|
||||||
observation retains its own initiator evidence.
|
observation retains its own initiator evidence.
|
||||||
|
|
||||||
|
|||||||
+38
-5
@@ -319,11 +319,44 @@ redirected, which keeps large results smaller for agents and scripts; pipe
|
|||||||
through `jq .` for indented files. Output selection and formatting do not change
|
through `jq .` for indented files. Output selection and formatting do not change
|
||||||
operation status.
|
operation status.
|
||||||
|
|
||||||
| Status | Meaning |
|
For `cutter-command`, `--json-output` requests command-specific JSON from
|
||||||
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
Cutter when available. This is separate from `--format json`, which selects the
|
||||||
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
|
CLI result representation; the global `--json` flag selects that same CLI
|
||||||
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
|
representation.
|
||||||
| `128 + N` | Signal `N` ended the process, where the shell or runtime preserves the conventional signal-derived status. |
|
|
||||||
|
Cutter still accepts deprecated `--json=true` and `--json=false` compatibility
|
||||||
|
values. When both forms are present, `--json-output` takes precedence in either
|
||||||
|
argument order. Bare `--json` retains its global meaning of selecting JSON CLI
|
||||||
|
output formatting.
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `0` | The operation completed. Its result may include partial evidence, warnings or unresolved questions. |
|
||||||
|
| `1` | The operation could not complete. Structured output identifies invalid input, permissions, cancellation, timeouts or another failure when available. |
|
||||||
|
| `128 + N` | Signal-derived status. An unhandled signal may have ended the process, with the shell or runtime preserving the conventional status; a command may instead have handled the signal, waited for its operation and cleanup to settle, and assigned the same status (`130` for SIGINT, `143` for SIGTERM). In the handled case, it may also have printed a structured `cancelled` result. |
|
||||||
|
|
||||||
|
A `cancelled` result does not by itself determine the exit status; it depends on
|
||||||
|
the command's signal handling. Commands routed through the shared
|
||||||
|
`withCommandCancellation` handler in the CLI set status `130` for SIGINT or
|
||||||
|
`143` for SIGTERM after their operation settles. Current source registrations
|
||||||
|
use that handler for process capture and comparison (`capture-process`,
|
||||||
|
`compare-process-captures`), JavaScript application analysis (`analyze` when it
|
||||||
|
selects the JavaScript lane, and `analyze-javascript-application`), selected
|
||||||
|
browser, web and Electron commands (including `analyze-web-bundle`,
|
||||||
|
`observe-web-execution`, `capture-browser-scenario` and
|
||||||
|
`capture-electron-scenario`), and the platform and provider command families
|
||||||
|
for Android/ADB, Apktool/JEB/Flutter, firmware, PCAP and signal analysis,
|
||||||
|
WASM/EVM, Go/Rust and binary diagnostics, Frida instrumentation, the Cutter
|
||||||
|
command, objdump, Rizin and GDB. In these paths, a returned structured
|
||||||
|
`cancelled` result can be printed before the wrapper assigns the
|
||||||
|
signal-derived status. This describes the current source routing; it is not a
|
||||||
|
claim that every command or provider runtime has been independently exercised.
|
||||||
|
|
||||||
|
`inspect-artifact` also returns a structured cancelled result, but its direct
|
||||||
|
analysis cancellation path does not set a signal-derived exit status, so it
|
||||||
|
exits `1`. Setup has its own contract: cancellation exits `0`. Treat the
|
||||||
|
structured result and process status as separate signals when scripting
|
||||||
|
cancellation.
|
||||||
|
|
||||||
`setup --dry-run` returns `planned` and exits `0`; a cancelled setup also exits
|
`setup --dry-run` returns `planned` and exits `0`; a cancelled setup also exits
|
||||||
`0`. Setup returns `1` for `needs_confirmation` or `needs_human`.
|
`0`. Setup returns `1` for `needs_confirmation` or `needs_human`.
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ provider implicitly.
|
|||||||
`trace_javascript_semantics` queries the authenticated companion graph without
|
`trace_javascript_semantics` queries the authenticated companion graph without
|
||||||
rereading mutable files. A seed may identify a semantic or application node,
|
rereading mutable files. A seed may identify a semantic or application node,
|
||||||
literal, function fingerprint, property, endpoint, event, or boundary field.
|
literal, function fingerprint, property, endpoint, event, or boundary field.
|
||||||
|
For a recovered parse, coercion, or validation boundary, its field value is the
|
||||||
|
static operation's method name (for example, `Number`).
|
||||||
The query declares backward provenance, forward influence, callers, or
|
The query declares backward provenance, forward influence, callers, or
|
||||||
ownership and can restrict admitted relation kinds. REA returns every matching
|
ownership and can restrict admitted relation kinds. REA returns every matching
|
||||||
seed and the complete reachable subgraph; callers do not choose node, relation,
|
seed and the complete reachable subgraph; callers do not choose node, relation,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ URL after validating its syntax. That validation does not establish that the
|
|||||||
JEB service is reachable or that a project is open; use the existing
|
JEB service is reachable or that a project is open; use the existing
|
||||||
`inspect-jeb-client` command for a read-only service query.
|
`inspect-jeb-client` command for a read-only service query.
|
||||||
|
|
||||||
|
MCP `binary_session` probes the live JEB service and its advertised tool catalog.
|
||||||
|
Availability is reported per operation: a reachable headless client can support
|
||||||
|
inspection and decompilation while refusing `open_jeb_project`. Catalog discovery
|
||||||
|
uses one 60-second deadline across connection and pagination; a responsive server
|
||||||
|
cannot keep discovery running indefinitely by returning more pages.
|
||||||
|
|
||||||
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
|
Verified against JEB 5.48.0 (Linux x64, licensed build) serving `jeb-mcp-server` 1.3.0 over streamable HTTP.
|
||||||
|
|
||||||
## Operations
|
## Operations
|
||||||
|
|||||||
@@ -38,13 +38,10 @@ socket and device entries are not opened. Case-distinct archive members remain
|
|||||||
separate observations. Their case-insensitive materialization diagnostic does
|
separate observations. Their case-insensitive materialization diagnostic does
|
||||||
not reduce traversal coverage when both spellings were read.
|
not reduce traversal coverage when both spellings were read.
|
||||||
|
|
||||||
Current output also retains `coverage_status` (`complete` or `partial`) for existing clients, derived from the scoped traversal status. The owning parser normalizes retained `coverage_status` records to scoped coverage and rejects conflicting declarations.
|
Current inventory Evidence requires scoped `coverage` with a `complete` or
|
||||||
|
`unknown` status. Consumers use this producer-owned fact and reject records
|
||||||
The coverage field is optional when reading older retained Evidence. The owning
|
without it; limitations are explanatory and do not supply or infer coverage.
|
||||||
inventory parser recognizes historical REA scope notices for compatibility;
|
Pages from one inventory must carry the same coverage declaration.
|
||||||
unfamiliar legacy limitations remain conservative. Typed records do not derive
|
|
||||||
coverage from limitation text. Pages from one inventory must carry the same
|
|
||||||
coverage declaration.
|
|
||||||
|
|
||||||
Function dossiers can declare per-facet coverage in the optional
|
Function dossiers can declare per-facet coverage in the optional
|
||||||
`normalized_result.facet_availability` map. Each supplied facet is either
|
`normalized_result.facet_availability` map. Each supplied facet is either
|
||||||
|
|||||||
@@ -481,8 +481,9 @@ user's permissions and may change files, show UI or use the network.
|
|||||||
|
|
||||||
Debugging must be allowed for the user (Developer Tools access). A
|
Debugging must be allowed for the user (Developer Tools access). A
|
||||||
hardened-runtime target without `com.apple.security.get-task-allow` cannot be
|
hardened-runtime target without `com.apple.security.get-task-allow` cannot be
|
||||||
debugged; `inspect_signature` reports this as the debugger-attach facet, and
|
debugged; `inspect_signature` reports the signature's `hardened_runtime` value
|
||||||
the tool fails with a `debugger-attach-denied` reason.
|
and its `entitlements`, including `com.apple.security.get-task-allow` when it
|
||||||
|
is present. A denied trace reports the `debugger-attach-denied` reason.
|
||||||
|
|
||||||
## Provider and verification boundaries
|
## Provider and verification boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ its manifest. Release CI builds both, and `prepublishOnly` rejects a missing,
|
|||||||
stale, mismatched, or wrong-architecture artifact. A development tarball built
|
stale, mismatched, or wrong-architecture artifact. A development tarball built
|
||||||
without this lane reports Windows native controls as unavailable.
|
without this lane reports Windows native controls as unavailable.
|
||||||
|
|
||||||
|
## Restore the artifact in a source checkout
|
||||||
|
|
||||||
|
The source tree omits `native/windows/build/` from Git. To run Windows
|
||||||
|
provider-backed workflows from a checkout without the MinGW build lane, extract
|
||||||
|
the two files from the published `rea-agents` tarball whose version exactly
|
||||||
|
matches the checkout's `package.json` version. Do not use `latest` or copy files
|
||||||
|
from another REA version; the loader verifies the manifest version, ABI,
|
||||||
|
architecture, and addon digest before it grants authority.
|
||||||
|
|
||||||
|
From the checkout root, replace `<matching-version>` with the `version` in
|
||||||
|
`package.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm pack rea-agents@<matching-version>
|
||||||
|
tar -xzf rea-agents-<matching-version>.tgz
|
||||||
|
mkdir -p native/windows/build
|
||||||
|
cp package/native/windows/build/manifest.json native/windows/build/
|
||||||
|
cp package/native/windows/build/rea-windows-x64.node native/windows/build/
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a manual recovery step. REA does not fetch or install the artifact
|
||||||
|
automatically. Alternatively, build it using the Linux/MinGW lane above.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- Opaque Node objects carry a native type tag, resource kind, and closed state.
|
- Opaque Node objects carry a native type tag, resource kind, and closed state.
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export const SCOPES = {
|
|||||||
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
"src/domain/javascript/javascriptRuntimeStaticCandidates.ts",
|
||||||
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
|
"tests/boundary/filesystem/webScriptManifestPaths.test.ts",
|
||||||
"tests/boundary/process/ProcessEvidence.host.test.ts",
|
"tests/boundary/process/ProcessEvidence.host.test.ts",
|
||||||
|
"tests/boundary/process/windowsOutputCapture.test.ts",
|
||||||
"tests/acceptance/webModuleCli.test.ts",
|
"tests/acceptance/webModuleCli.test.ts",
|
||||||
"tests/acceptance/webSourceLocationCli.test.ts",
|
"tests/acceptance/webSourceLocationCli.test.ts",
|
||||||
"tests/boundary/cli/referenceSourceImportStatus.test.ts",
|
"tests/boundary/cli/referenceSourceImportStatus.test.ts",
|
||||||
|
|||||||
@@ -144,14 +144,19 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const assertRejected = async (scenario) => {
|
const assertRejected = async (scenario, expectedReason) => {
|
||||||
const result = await provider.captureScenario(scenario);
|
const result = await provider.captureScenario(scenario);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.ok,
|
result.ok,
|
||||||
false,
|
false,
|
||||||
"Worker-influenced capture unexpectedly succeeded",
|
"Worker-influenced capture unexpectedly succeeded",
|
||||||
);
|
);
|
||||||
assert.equal(result.error.reason, "target_not_allowed");
|
assert.equal(result.error.reason, expectedReason);
|
||||||
|
if (expectedReason === "service_worker_blocked")
|
||||||
|
assert.match(
|
||||||
|
result.error.userMessage,
|
||||||
|
/environment\.service_workers to allow/,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
result.error.partialObservation.capture.browser.cleanup,
|
result.error.partialObservation.capture.browser.cleanup,
|
||||||
"disconnected-external",
|
"disconnected-external",
|
||||||
@@ -229,7 +234,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
|
|||||||
"worker",
|
"worker",
|
||||||
);
|
);
|
||||||
await page.goto("about:blank");
|
await page.goto("about:blank");
|
||||||
await assertRejected(scenarioFor(second.origin));
|
await assertRejected(scenarioFor(second.origin), "service_worker_blocked");
|
||||||
await page.goto("about:blank");
|
await page.goto("about:blank");
|
||||||
await assertRejected(
|
await assertRejected(
|
||||||
scenarioFor(first.origin, [
|
scenarioFor(first.origin, [
|
||||||
@@ -240,6 +245,7 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
|
|||||||
wait_until: "load",
|
wait_until: "load",
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
"service_worker_blocked",
|
||||||
);
|
);
|
||||||
await page.goto("about:blank");
|
await page.goto("about:blank");
|
||||||
await assertRejected(
|
await assertRejected(
|
||||||
@@ -251,9 +257,13 @@ export async function verifyScenarioServiceWorkers(endpoint, targetId) {
|
|||||||
},
|
},
|
||||||
{ step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 },
|
{ step_id: "loaded", action: "wait_for_timeout", duration_ms: 100 },
|
||||||
]),
|
]),
|
||||||
|
"service_worker_blocked",
|
||||||
);
|
);
|
||||||
await page.goto("about:blank");
|
await page.goto("about:blank");
|
||||||
await assertRejected(scenarioFor(`${first.origin}/frame`));
|
await assertRejected(
|
||||||
|
scenarioFor(`${first.origin}/frame`),
|
||||||
|
"service_worker_blocked",
|
||||||
|
);
|
||||||
|
|
||||||
const allowed = await provider.captureScenario(
|
const allowed = await provider.captureScenario(
|
||||||
scenarioFor(second.origin, [], "allow"),
|
scenarioFor(second.origin, [], "allow"),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { ToolKind } from "../contracts/toolContractTypes.js";
|
|||||||
import type { JsonValue } from "../domain/jsonValue.js";
|
import type { JsonValue } from "../domain/jsonValue.js";
|
||||||
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
|
import { isMachOFormat } from "../domain/binaryTargetTypes.js";
|
||||||
import type { ProviderAvailability } from "./AnalysisProvider.js";
|
import type { ProviderAvailability } from "./AnalysisProvider.js";
|
||||||
|
import type { JebAnalysisAvailability } from "./jeb/JebAnalysisPort.js";
|
||||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||||
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
|
import { FRIDA_TOOL_CONTRACTS } from "../contracts/fridaToolContracts.js";
|
||||||
import {
|
import {
|
||||||
@@ -56,7 +57,7 @@ export type AvailabilityPolicy = {
|
|||||||
readonly androidAnalysisAvailability?: ProviderAvailability;
|
readonly androidAnalysisAvailability?: ProviderAvailability;
|
||||||
readonly adbAnalysisAvailability?: ProviderAvailability;
|
readonly adbAnalysisAvailability?: ProviderAvailability;
|
||||||
readonly apktoolAnalysisAvailability?: ProviderAvailability;
|
readonly apktoolAnalysisAvailability?: ProviderAvailability;
|
||||||
readonly jebAnalysisAvailability?: ProviderAvailability;
|
readonly jebAnalysisAvailability?: JebAnalysisAvailability;
|
||||||
readonly javascriptRecoveryEnabled?: boolean;
|
readonly javascriptRecoveryEnabled?: boolean;
|
||||||
readonly webModuleResolutionEnabled?: boolean;
|
readonly webModuleResolutionEnabled?: boolean;
|
||||||
readonly browserObservationEnabled?: boolean;
|
readonly browserObservationEnabled?: boolean;
|
||||||
@@ -485,15 +486,22 @@ const workflowAvailabilityFor = ({
|
|||||||
policy.apktoolAnalysisAvailability,
|
policy.apktoolAnalysisAvailability,
|
||||||
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
|
"Set REA_APKTOOL_COMMAND to a caller-supplied Apktool executable or make Apktool 2.7.0 or newer available on PATH.",
|
||||||
);
|
);
|
||||||
if (kind === "jeb-provider")
|
if (kind === "jeb-provider") {
|
||||||
return policy.jebAnalysisAvailability?.status === "available"
|
const availability = policy.jebAnalysisAvailability;
|
||||||
? { reason: "available", remediation: null }
|
if (availability?.status === "available")
|
||||||
: {
|
return availability.supportedOperations.some(
|
||||||
reason: "provider_missing",
|
(operation) => operation === name,
|
||||||
remediation:
|
)
|
||||||
policy.jebAnalysisAvailability?.reason ??
|
? { reason: "available", remediation: null }
|
||||||
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
|
: {
|
||||||
};
|
reason: "provider_unavailable",
|
||||||
|
remediation: `The running JEB endpoint does not advertise support for ${name}. Use a JEB client build that provides this operation.`,
|
||||||
|
};
|
||||||
|
return ownerProbeAvailability(
|
||||||
|
availability,
|
||||||
|
"Start a JEB client serving MCP (GUI client, or headless via its programmatic launcher) and point REA_JEB_MCP_URL at it (default http://127.0.0.1:8425/mcp). REA does not install or launch JEB.",
|
||||||
|
);
|
||||||
|
}
|
||||||
const browser = browserProviderAvailability(name, kind, policy);
|
const browser = browserProviderAvailability(name, kind, policy);
|
||||||
if (browser !== null) return browser;
|
if (browser !== null) return browser;
|
||||||
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
installMacHopper,
|
installMacHopper,
|
||||||
|
installMacHopperBundle,
|
||||||
type MacHopperBundleVerification,
|
type MacHopperBundleVerification,
|
||||||
|
type MacHopperBundleInstallResult,
|
||||||
type MacHopperInstallHost,
|
type MacHopperInstallHost,
|
||||||
} from "./MacHopper.js";
|
} from "./MacHopper.js";
|
||||||
|
|
||||||
@@ -15,7 +17,8 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
|||||||
existing = false;
|
existing = false;
|
||||||
mountSucceeds = true;
|
mountSucceeds = true;
|
||||||
copySucceeds = true;
|
copySucceeds = true;
|
||||||
launcherSucceeds = true;
|
mountFailure: Error | undefined;
|
||||||
|
verificationFailure: Error | undefined;
|
||||||
badHash = false;
|
badHash = false;
|
||||||
unmounts = 0;
|
unmounts = 0;
|
||||||
cleanups = 0;
|
cleanups = 0;
|
||||||
@@ -46,25 +49,37 @@ class FakeMacHopperHost implements MacHopperInstallHost {
|
|||||||
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
|
||||||
createMountDirectory = () => Promise.resolve();
|
createMountDirectory = () => Promise.resolve();
|
||||||
writePackage = () => Promise.resolve();
|
writePackage = () => Promise.resolve();
|
||||||
mount = () => Promise.resolve(this.mountSucceeds);
|
mount = () =>
|
||||||
|
this.mountFailure === undefined
|
||||||
|
? Promise.resolve(this.mountSucceeds)
|
||||||
|
: Promise.reject(this.mountFailure);
|
||||||
unmount = () => {
|
unmount = () => {
|
||||||
this.unmounts += 1;
|
this.unmounts += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
|
||||||
validBundle = () => Promise.resolve(this.verification);
|
validBundle = () =>
|
||||||
|
this.verificationFailure === undefined
|
||||||
|
? Promise.resolve(this.verification)
|
||||||
|
: Promise.reject(this.verificationFailure);
|
||||||
destinationExists = () => Promise.resolve(this.existing);
|
destinationExists = () => Promise.resolve(this.existing);
|
||||||
installBundle = (
|
installBundle = (
|
||||||
_source: string,
|
_source: string,
|
||||||
_destination: string,
|
_destination: string,
|
||||||
_stage: string,
|
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
) => {
|
) => {
|
||||||
this.installs += 1;
|
this.installs += 1;
|
||||||
this.replaces = replaceExisting;
|
this.replaces = replaceExisting;
|
||||||
return Promise.resolve(this.copySucceeds);
|
return Promise.resolve<MacHopperBundleInstallResult>(
|
||||||
|
this.copySucceeds
|
||||||
|
? { status: "installed" }
|
||||||
|
: {
|
||||||
|
status: "failed",
|
||||||
|
reason: "copy",
|
||||||
|
diagnostic: "ditto: destination is read-only",
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
launcherReady = () => Promise.resolve(this.launcherSucceeds);
|
|
||||||
openApplication = () => {
|
openApplication = () => {
|
||||||
this.opens += 1;
|
this.opens += 1;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -89,6 +104,22 @@ describe("macOS Hopper installation", () => {
|
|||||||
expect(host.cleanups).toBe(1);
|
expect(host.cleanups).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves installation when macOS cannot open the installed app", async () => {
|
||||||
|
const host = new FakeMacHopperHost();
|
||||||
|
host.openApplication = () =>
|
||||||
|
Promise.reject(new Error("LaunchServices refused"));
|
||||||
|
|
||||||
|
await expect(installMacHopper({}, host)).resolves.toEqual({
|
||||||
|
status: "installed",
|
||||||
|
launcherPath:
|
||||||
|
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||||
|
openFailure: "LaunchServices refused",
|
||||||
|
});
|
||||||
|
expect(host.installs).toBe(1);
|
||||||
|
expect(host.unmounts).toBe(1);
|
||||||
|
expect(host.cleanups).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an integrity mismatch before mounting", async () => {
|
it("rejects an integrity mismatch before mounting", async () => {
|
||||||
const host = new FakeMacHopperHost();
|
const host = new FakeMacHopperHost();
|
||||||
host.badHash = true;
|
host.badHash = true;
|
||||||
@@ -130,6 +161,33 @@ describe("macOS Hopper installation", () => {
|
|||||||
expect(host.cleanups).toBe(1);
|
expect(host.cleanups).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a thrown mount-stage diagnostic and cleans the temporary image", async () => {
|
||||||
|
const host = new FakeMacHopperHost();
|
||||||
|
host.mountFailure = new Error("hdiutil attach: resource busy");
|
||||||
|
|
||||||
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
reason: "mount",
|
||||||
|
diagnostic: "hdiutil attach: resource busy",
|
||||||
|
});
|
||||||
|
expect(host.unmounts).toBe(0);
|
||||||
|
expect(host.cleanups).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the copy diagnostic from the owned install operation", async () => {
|
||||||
|
const host = new FakeMacHopperHost();
|
||||||
|
host.copySucceeds = false;
|
||||||
|
|
||||||
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
reason: "copy",
|
||||||
|
diagnostic: "ditto: destination is read-only",
|
||||||
|
});
|
||||||
|
expect(host.installs).toBe(1);
|
||||||
|
expect(host.unmounts).toBe(1);
|
||||||
|
expect(host.cleanups).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
it("reports a publisher mismatch without copying or launching the bundle", async () => {
|
||||||
const host = new FakeMacHopperHost();
|
const host = new FakeMacHopperHost();
|
||||||
host.verification = "unverified_publisher";
|
host.verification = "unverified_publisher";
|
||||||
@@ -142,4 +200,18 @@ describe("macOS Hopper installation", () => {
|
|||||||
expect(host.unmounts).toBe(1);
|
expect(host.unmounts).toBe(1);
|
||||||
expect(host.cleanups).toBe(1);
|
expect(host.cleanups).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves a thrown signature-check diagnostic as a bundle failure", async () => {
|
||||||
|
const host = new FakeMacHopperHost();
|
||||||
|
host.verificationFailure = new Error("codesign: helper unavailable");
|
||||||
|
|
||||||
|
await expect(installMacHopper({}, host)).resolves.toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
reason: "bundle",
|
||||||
|
diagnostic: "codesign: helper unavailable",
|
||||||
|
});
|
||||||
|
expect(host.installs).toBe(0);
|
||||||
|
expect(host.unmounts).toBe(1);
|
||||||
|
expect(host.cleanups).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+240
-90
@@ -10,7 +10,7 @@ import {
|
|||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
downloadHopperPackage,
|
downloadHopperPackage,
|
||||||
HOPPER_DOWNLOAD_BYTES,
|
HOPPER_DOWNLOAD_BYTES,
|
||||||
} from "./HopperDownload.js";
|
} from "./HopperDownload.js";
|
||||||
|
import type { HopperInstallFailureReason } from "./SetupInstallFailure.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const RELEASES_URL =
|
const RELEASES_URL =
|
||||||
@@ -38,6 +39,38 @@ export type MacHopperBundleVerification =
|
|||||||
| "invalid_signature"
|
| "invalid_signature"
|
||||||
| "unverified_publisher";
|
| "unverified_publisher";
|
||||||
|
|
||||||
|
type MacHopperInstallFailureReason = Extract<
|
||||||
|
HopperInstallFailureReason,
|
||||||
|
| "release_metadata"
|
||||||
|
| "download"
|
||||||
|
| "integrity"
|
||||||
|
| "launcher_missing"
|
||||||
|
| "destination_exists"
|
||||||
|
| "mount"
|
||||||
|
| "bundle"
|
||||||
|
| "publisher_verification_failed"
|
||||||
|
| "copy"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type MacHopperBundleInstallResult =
|
||||||
|
| { readonly status: "installed"; readonly cleanupFailure?: string }
|
||||||
|
| {
|
||||||
|
readonly status: "failed";
|
||||||
|
readonly reason: "copy" | "launcher_missing";
|
||||||
|
readonly diagnostic: string;
|
||||||
|
readonly cleanupFailure?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Filesystem effects for one installer-owned bundle replacement transaction. */
|
||||||
|
export interface MacHopperBundleInstallOperations {
|
||||||
|
createStagingRoot(parent: string): Promise<string>;
|
||||||
|
copyBundle(source: string, stage: string): Promise<void>;
|
||||||
|
requireLauncher(path: string): Promise<void>;
|
||||||
|
pathExists(path: string): Promise<boolean>;
|
||||||
|
rename(source: string, destination: string): Promise<void>;
|
||||||
|
removeStagingRoot(path: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
/** Stable network and command effects disclosed before macOS Hopper setup. */
|
||||||
export const macHopperInstallDisclosure = {
|
export const macHopperInstallDisclosure = {
|
||||||
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
|
networkOrigins: [RELEASES_URL, DOWNLOAD_PREFIX] as const,
|
||||||
@@ -56,19 +89,12 @@ export type MacHopperInstallResult =
|
|||||||
readonly status: "installed";
|
readonly status: "installed";
|
||||||
readonly launcherPath: string;
|
readonly launcherPath: string;
|
||||||
readonly cleanupFailure?: string;
|
readonly cleanupFailure?: string;
|
||||||
|
readonly openFailure?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "failed";
|
readonly status: "failed";
|
||||||
readonly reason:
|
readonly reason: MacHopperInstallFailureReason;
|
||||||
| "release_metadata"
|
readonly diagnostic?: string;
|
||||||
| "download"
|
|
||||||
| "integrity"
|
|
||||||
| "mount"
|
|
||||||
| "bundle"
|
|
||||||
| "publisher_verification_failed"
|
|
||||||
| "destination_exists"
|
|
||||||
| "copy"
|
|
||||||
| "launcher_missing";
|
|
||||||
readonly cleanupFailure?: string;
|
readonly cleanupFailure?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -88,10 +114,8 @@ export interface MacHopperInstallHost {
|
|||||||
installBundle(
|
installBundle(
|
||||||
source: string,
|
source: string,
|
||||||
destination: string,
|
destination: string,
|
||||||
stage: string,
|
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
): Promise<boolean>;
|
): Promise<MacHopperBundleInstallResult>;
|
||||||
launcherReady(path: string): Promise<boolean>;
|
|
||||||
openApplication(path: string): Promise<void>;
|
openApplication(path: string): Promise<void>;
|
||||||
cleanup(path: string): Promise<void>;
|
cleanup(path: string): Promise<void>;
|
||||||
/** Retain the cleanup capability until the owning setup host retries it. */
|
/** Retain the cleanup capability until the owning setup host retries it. */
|
||||||
@@ -109,6 +133,8 @@ export const installMacHopper = async (
|
|||||||
): Promise<MacHopperInstallResult> => {
|
): Promise<MacHopperInstallResult> => {
|
||||||
let temporary: string | undefined;
|
let temporary: string | undefined;
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
let bundleCleanupFailure: string | undefined;
|
||||||
|
let installStage: MacHopperInstallFailureReason = "release_metadata";
|
||||||
let result: MacHopperInstallResult;
|
let result: MacHopperInstallResult;
|
||||||
try {
|
try {
|
||||||
result = await (async (): Promise<MacHopperInstallResult> => {
|
result = await (async (): Promise<MacHopperInstallResult> => {
|
||||||
@@ -120,15 +146,19 @@ export const installMacHopper = async (
|
|||||||
const release = parsed.data["OS X"];
|
const release = parsed.data["OS X"];
|
||||||
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
||||||
return { status: "failed", reason: "release_metadata" };
|
return { status: "failed", reason: "release_metadata" };
|
||||||
|
installStage = "download";
|
||||||
const archive = await host.download(release.filename);
|
const archive = await host.download(release.filename);
|
||||||
if (!archive.ok) return { status: "failed", reason: "download" };
|
if (!archive.ok) return { status: "failed", reason: "download" };
|
||||||
|
installStage = "integrity";
|
||||||
if (!integrityMatches(archive.bytes, release))
|
if (!integrityMatches(archive.bytes, release))
|
||||||
return { status: "failed", reason: "integrity" };
|
return { status: "failed", reason: "integrity" };
|
||||||
|
|
||||||
|
installStage = "destination_exists";
|
||||||
const destination = host.destination();
|
const destination = host.destination();
|
||||||
const destinationExists = await host.destinationExists(destination);
|
const destinationExists = await host.destinationExists(destination);
|
||||||
if (destinationExists && options.replaceExisting !== true)
|
if (destinationExists && options.replaceExisting !== true)
|
||||||
return { status: "failed", reason: "destination_exists" };
|
return { status: "failed", reason: "destination_exists" };
|
||||||
|
installStage = "mount";
|
||||||
temporary = await host.createTemporaryDirectory();
|
temporary = await host.createTemporaryDirectory();
|
||||||
const packagePath = join(temporary, "hopper.dmg");
|
const packagePath = join(temporary, "hopper.dmg");
|
||||||
const mountPath = join(temporary, "mounted");
|
const mountPath = join(temporary, "mounted");
|
||||||
@@ -137,6 +167,7 @@ export const installMacHopper = async (
|
|||||||
if (!(await host.mount(packagePath, mountPath)))
|
if (!(await host.mount(packagePath, mountPath)))
|
||||||
return { status: "failed", reason: "mount" };
|
return { status: "failed", reason: "mount" };
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
installStage = "bundle";
|
||||||
const source = await host.appBundle(mountPath);
|
const source = await host.appBundle(mountPath);
|
||||||
if (source === undefined) return { status: "failed", reason: "bundle" };
|
if (source === undefined) return { status: "failed", reason: "bundle" };
|
||||||
const verification = await host.validBundle(source);
|
const verification = await host.validBundle(source);
|
||||||
@@ -144,26 +175,41 @@ export const installMacHopper = async (
|
|||||||
return { status: "failed", reason: "bundle" };
|
return { status: "failed", reason: "bundle" };
|
||||||
if (verification === "unverified_publisher")
|
if (verification === "unverified_publisher")
|
||||||
return { status: "failed", reason: "publisher_verification_failed" };
|
return { status: "failed", reason: "publisher_verification_failed" };
|
||||||
const stage = `${destination}.rea-stage`;
|
installStage = "copy";
|
||||||
if (
|
const installation = await host.installBundle(
|
||||||
!(await host.installBundle(
|
source,
|
||||||
source,
|
destination,
|
||||||
destination,
|
destinationExists,
|
||||||
stage,
|
);
|
||||||
destinationExists,
|
if (installation.status === "failed")
|
||||||
))
|
return {
|
||||||
)
|
status: "failed",
|
||||||
return { status: "failed", reason: "copy" };
|
reason: installation.reason,
|
||||||
|
diagnostic: installation.diagnostic,
|
||||||
|
...(installation.cleanupFailure === undefined
|
||||||
|
? {}
|
||||||
|
: { cleanupFailure: installation.cleanupFailure }),
|
||||||
|
};
|
||||||
|
bundleCleanupFailure = installation.cleanupFailure;
|
||||||
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
||||||
if (!(await host.launcherReady(launcherPath)))
|
let openFailure: string | undefined;
|
||||||
return { status: "failed", reason: "launcher_missing" };
|
try {
|
||||||
await host.openApplication(destination);
|
await host.openApplication(destination);
|
||||||
return { status: "installed", launcherPath };
|
} catch (cause: unknown) {
|
||||||
|
openFailure = failureMessage(cause);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "installed",
|
||||||
|
launcherPath,
|
||||||
|
...(openFailure === undefined ? {} : { openFailure }),
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// Install-stage failures share the download reason; the stage is cleaned below.
|
result = {
|
||||||
void cause;
|
status: "failed",
|
||||||
result = { status: "failed", reason: "download" };
|
reason: installStage,
|
||||||
|
diagnostic: failureMessage(cause),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleanupFailure: string | undefined;
|
let cleanupFailure: string | undefined;
|
||||||
@@ -185,7 +231,106 @@ export const installMacHopper = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanupFailure === undefined ? result : { ...result, cleanupFailure };
|
const retainedCleanupFailure = [
|
||||||
|
bundleCleanupFailure,
|
||||||
|
cleanupFailure,
|
||||||
|
result.cleanupFailure,
|
||||||
|
]
|
||||||
|
.filter((failure): failure is string => failure !== undefined)
|
||||||
|
.join(" ");
|
||||||
|
return retainedCleanupFailure === ""
|
||||||
|
? result
|
||||||
|
: { ...result, cleanupFailure: retainedCleanupFailure };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Replace a bundle through a fresh same-volume root owned by this transaction. */
|
||||||
|
export const installMacHopperBundle = async (
|
||||||
|
source: string,
|
||||||
|
destination: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
operations: MacHopperBundleInstallOperations,
|
||||||
|
retainCleanup: (cleanup: () => Promise<void>) => void,
|
||||||
|
): Promise<MacHopperBundleInstallResult> => {
|
||||||
|
const stagingRoot = await operations.createStagingRoot(dirname(destination));
|
||||||
|
const stagedBundle = join(stagingRoot, "new.app");
|
||||||
|
const previousBundle = join(stagingRoot, "previous.app");
|
||||||
|
let originalBackedUp = false;
|
||||||
|
let newCommitted = false;
|
||||||
|
let failureReason: "copy" | "launcher_missing" = "copy";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await operations.copyBundle(source, stagedBundle);
|
||||||
|
failureReason = "launcher_missing";
|
||||||
|
await operations.requireLauncher(
|
||||||
|
join(stagedBundle, "Contents/MacOS/hopper"),
|
||||||
|
);
|
||||||
|
failureReason = "copy";
|
||||||
|
if (replaceExisting) {
|
||||||
|
await operations.rename(destination, previousBundle);
|
||||||
|
originalBackedUp = true;
|
||||||
|
}
|
||||||
|
await operations.rename(stagedBundle, destination);
|
||||||
|
newCommitted = true;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
const diagnostics = [failureMessage(cause)];
|
||||||
|
if (originalBackedUp && !newCommitted) {
|
||||||
|
try {
|
||||||
|
if (await operations.pathExists(destination))
|
||||||
|
throw new Error(
|
||||||
|
"destination now contains another installation; preserving the previous bundle in its owned staging root",
|
||||||
|
);
|
||||||
|
await operations.rename(previousBundle, destination);
|
||||||
|
originalBackedUp = false;
|
||||||
|
} catch (rollbackCause: unknown) {
|
||||||
|
diagnostics.push(`rollback failed: ${failureMessage(rollbackCause)}`);
|
||||||
|
const cleanupFailure = `The previous Hopper bundle remains owned at ${previousBundle}; cleanup will retry restoration only if the destination is empty.`;
|
||||||
|
retainCleanup(async () => {
|
||||||
|
if (originalBackedUp) {
|
||||||
|
if (await operations.pathExists(destination))
|
||||||
|
throw new Error(
|
||||||
|
`Hopper destination is occupied; preserving owned backup ${previousBundle}`,
|
||||||
|
);
|
||||||
|
await operations.rename(previousBundle, destination);
|
||||||
|
originalBackedUp = false;
|
||||||
|
}
|
||||||
|
await operations.removeStagingRoot(stagingRoot);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
reason: failureReason,
|
||||||
|
diagnostic: diagnostics.join("; "),
|
||||||
|
cleanupFailure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await operations.removeStagingRoot(stagingRoot);
|
||||||
|
} catch (cleanupCause: unknown) {
|
||||||
|
const cleanupFailure = `Hopper staging root cleanup failed: ${failureMessage(cleanupCause)}`;
|
||||||
|
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||||
|
diagnostics.push(cleanupFailure);
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
reason: failureReason,
|
||||||
|
diagnostic: diagnostics.join("; "),
|
||||||
|
cleanupFailure,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
reason: failureReason,
|
||||||
|
diagnostic: diagnostics.join("; "),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await operations.removeStagingRoot(stagingRoot);
|
||||||
|
return { status: "installed" };
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
const cleanupFailure = `Hopper staging root cleanup failed after installation committed: ${failureMessage(cause)}`;
|
||||||
|
retainCleanup(() => operations.removeStagingRoot(stagingRoot));
|
||||||
|
return { status: "installed", cleanupFailure };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compose installer paths and subprocesses from one selected caller context. */
|
/** Compose installer paths and subprocesses from one selected caller context. */
|
||||||
@@ -200,7 +345,7 @@ export const systemMacHopperInstallHost = (
|
|||||||
createMountDirectory: (path) => mkdir(path),
|
createMountDirectory: (path) => mkdir(path),
|
||||||
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||||
async mount(packagePath, mountPath) {
|
async mount(packagePath, mountPath) {
|
||||||
return commandSucceeds(
|
await execFileAsync(
|
||||||
"hdiutil",
|
"hdiutil",
|
||||||
[
|
[
|
||||||
"attach",
|
"attach",
|
||||||
@@ -210,20 +355,14 @@ export const systemMacHopperInstallHost = (
|
|||||||
mountPath,
|
mountPath,
|
||||||
packagePath,
|
packagePath,
|
||||||
],
|
],
|
||||||
environment,
|
{ env: environment },
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
async unmount(mountPath) {
|
async unmount(mountPath) {
|
||||||
if (
|
await execFileAsync("hdiutil", ["detach", mountPath, "-force"], {
|
||||||
!(await commandSucceeds(
|
env: environment,
|
||||||
"hdiutil",
|
});
|
||||||
["detach", mountPath, "-force"],
|
|
||||||
environment,
|
|
||||||
))
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
`hdiutil detach failed for ${JSON.stringify(mountPath)}`,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
async appBundle(mountPath) {
|
async appBundle(mountPath) {
|
||||||
const entries = await readdir(mountPath, { withFileTypes: true });
|
const entries = await readdir(mountPath, { withFileTypes: true });
|
||||||
@@ -261,49 +400,32 @@ export const systemMacHopperInstallHost = (
|
|||||||
await access(path);
|
await access(path);
|
||||||
return true;
|
return true;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// best-effort cleanup: optional existence probing; absence means missing.
|
if (isMissingPath(cause)) return false;
|
||||||
void cause;
|
throw cause;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async installBundle(source, destination, stage, replaceExisting) {
|
|
||||||
const previous = `${stage}-previous`;
|
|
||||||
try {
|
|
||||||
await mkdir(join(homeDirectory, "Applications"), { recursive: true });
|
|
||||||
await rm(stage, { recursive: true, force: true });
|
|
||||||
await rm(previous, { recursive: true, force: true });
|
|
||||||
await execFileAsync("ditto", [source, stage], { env: environment });
|
|
||||||
await access(join(stage, "Contents/MacOS/hopper"));
|
|
||||||
if (replaceExisting) await rename(destination, previous);
|
|
||||||
await rename(stage, destination);
|
|
||||||
if (replaceExisting)
|
|
||||||
await rm(previous, { recursive: true, force: true });
|
|
||||||
return true;
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
// Install failure rolls back below; the false return reports it.
|
|
||||||
void cause;
|
|
||||||
await rm(stage, { recursive: true, force: true });
|
|
||||||
if (replaceExisting) {
|
|
||||||
try {
|
|
||||||
await rename(previous, destination);
|
|
||||||
} catch (rollbackCause: unknown) {
|
|
||||||
// The original destination was not moved, or rollback is impossible.
|
|
||||||
void rollbackCause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async launcherReady(path) {
|
|
||||||
try {
|
|
||||||
await access(path);
|
|
||||||
return true;
|
|
||||||
} catch (cause: unknown) {
|
|
||||||
// best-effort cleanup: optional launcher probing; absence means not ready.
|
|
||||||
void cause;
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
installBundle: (source, destination, replaceExisting) =>
|
||||||
|
installMacHopperBundle(
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
replaceExisting,
|
||||||
|
{
|
||||||
|
async createStagingRoot(parent) {
|
||||||
|
await mkdir(parent, { recursive: true });
|
||||||
|
return mkdtemp(join(parent, ".rea-hopper-stage-"));
|
||||||
|
},
|
||||||
|
copyBundle: async (bundle, staged) => {
|
||||||
|
await execFileAsync("ditto", [bundle, staged], {
|
||||||
|
env: environment,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
requireLauncher: (path) => access(path).then(() => undefined),
|
||||||
|
pathExists,
|
||||||
|
rename,
|
||||||
|
removeStagingRoot: (path) => rm(path, { recursive: true }),
|
||||||
|
},
|
||||||
|
(cleanup) => pendingCleanup.add(cleanup),
|
||||||
|
),
|
||||||
async openApplication(path) {
|
async openApplication(path) {
|
||||||
await execFileAsync("open", [path], { env: environment });
|
await execFileAsync("open", [path], { env: environment });
|
||||||
},
|
},
|
||||||
@@ -316,7 +438,7 @@ export const systemMacHopperInstallHost = (
|
|||||||
await cleanup();
|
await cleanup();
|
||||||
pendingCleanup.delete(cleanup);
|
pendingCleanup.delete(cleanup);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
failure ??= `Hopper DMG cleanup remains incomplete: ${failureMessage(cause)}`;
|
failure ??= `Hopper installer cleanup remains incomplete: ${failureMessage(cause)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failure;
|
return failure;
|
||||||
@@ -343,13 +465,41 @@ const commandSucceeds = async (
|
|||||||
await execFileAsync(command, args, { env: environment });
|
await execFileAsync(command, args, { env: environment });
|
||||||
return true;
|
return true;
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
// best-effort cleanup: optional command probing; failure means unavailable.
|
if (
|
||||||
void cause;
|
typeof cause === "object" &&
|
||||||
return false;
|
cause !== null &&
|
||||||
|
"code" in cause &&
|
||||||
|
typeof cause.code === "number"
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
throw cause;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const failureMessage = (cause: unknown): string =>
|
const failureMessage = (cause: unknown): string =>
|
||||||
cause instanceof Error ? cause.message : String(cause);
|
cause instanceof Error
|
||||||
|
? [
|
||||||
|
cause.message,
|
||||||
|
"stderr" in cause && typeof cause.stderr === "string"
|
||||||
|
? cause.stderr.trim()
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
.filter((value) => value !== "")
|
||||||
|
.join(": ")
|
||||||
|
: String(cause);
|
||||||
|
const isMissingPath = (cause: unknown): boolean =>
|
||||||
|
typeof cause === "object" &&
|
||||||
|
cause !== null &&
|
||||||
|
"code" in cause &&
|
||||||
|
cause.code === "ENOENT";
|
||||||
|
const pathExists = async (path: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
return true;
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
if (isMissingPath(cause)) return false;
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
};
|
||||||
const releaseSchema = z.object({
|
const releaseSchema = z.object({
|
||||||
filename: z.string().url(),
|
filename: z.string().url(),
|
||||||
file_length: z.string().regex(/^\d+$/u),
|
file_length: z.string().regex(/^\d+$/u),
|
||||||
|
|||||||
@@ -311,6 +311,25 @@ describe("setup plan application", () => {
|
|||||||
expect(result.remediation).toBeUndefined();
|
expect(result.remediation).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("records an installed Hopper when opening the app needs manual recovery", async () => {
|
||||||
|
const host = new FakeSetupHost("darwin");
|
||||||
|
host.installHopper = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
status: "installed",
|
||||||
|
launcherPath: "/Applications/Hopper.app/Contents/MacOS/hopper",
|
||||||
|
warning:
|
||||||
|
"Hopper was installed, but macOS could not open it. Open it manually.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSetup(options(true, true), host);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: "needs_human",
|
||||||
|
appliedActions: ["installed_hopper"],
|
||||||
|
remediation: expect.stringContaining("Open it manually"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("reuses existing Hopper without selecting every detected client", async () => {
|
it("reuses existing Hopper without selecting every detected client", async () => {
|
||||||
const host = new FakeSetupHost();
|
const host = new FakeSetupHost();
|
||||||
host.hopper = "/Applications/Hopper";
|
host.hopper = "/Applications/Hopper";
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ const runSetupWorkflow = async (
|
|||||||
}
|
}
|
||||||
let selectedClients: readonly SetupClient[] =
|
let selectedClients: readonly SetupClient[] =
|
||||||
planSelection.clientsToConfigure;
|
planSelection.clientsToConfigure;
|
||||||
|
let hopperWarning: string | undefined;
|
||||||
if (
|
if (
|
||||||
planSelection.installHopper &&
|
planSelection.installHopper &&
|
||||||
(interactiveApproval || options.installHopper)
|
(interactiveApproval || options.installHopper)
|
||||||
@@ -322,7 +323,12 @@ const runSetupWorkflow = async (
|
|||||||
clientStates,
|
clientStates,
|
||||||
});
|
});
|
||||||
if ("failure" in install) return install.failure;
|
if ("failure" in install) return install.failure;
|
||||||
({ hopperPath, providerEnvironment, selectedClients } = install);
|
({
|
||||||
|
hopperPath,
|
||||||
|
providerEnvironment,
|
||||||
|
selectedClients,
|
||||||
|
warning: hopperWarning,
|
||||||
|
} = install);
|
||||||
}
|
}
|
||||||
const clientFailure = await configureDetectedClients({
|
const clientFailure = await configureDetectedClients({
|
||||||
host,
|
host,
|
||||||
@@ -355,6 +361,7 @@ const runSetupWorkflow = async (
|
|||||||
doctor.healthy,
|
doctor.healthy,
|
||||||
hopperPath,
|
hopperPath,
|
||||||
),
|
),
|
||||||
|
hopperWarning,
|
||||||
manualRemediation,
|
manualRemediation,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -407,14 +414,17 @@ const installHopperAction = async (input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
input.appliedActions.push("installed_hopper");
|
input.appliedActions.push("installed_hopper");
|
||||||
|
const warning = [installed.warning, installed.cleanupFailure]
|
||||||
|
.filter((value): value is string => value !== undefined)
|
||||||
|
.join(" ");
|
||||||
emitProgress(input.options, {
|
emitProgress(input.options, {
|
||||||
actionId: "install_hopper",
|
actionId: "install_hopper",
|
||||||
label,
|
label,
|
||||||
state: installed.cleanupFailure === undefined ? "completed" : "warning",
|
state: warning === "" ? "completed" : "warning",
|
||||||
detail:
|
detail:
|
||||||
installed.cleanupFailure === undefined
|
warning === ""
|
||||||
? installed.launcherPath
|
? installed.launcherPath
|
||||||
: `${installed.launcherPath}; ${installed.cleanupFailure}`,
|
: `${installed.launcherPath}; ${warning}`,
|
||||||
});
|
});
|
||||||
const providerEnvironment = {
|
const providerEnvironment = {
|
||||||
...input.providerEnvironment,
|
...input.providerEnvironment,
|
||||||
@@ -423,6 +433,7 @@ const installHopperAction = async (input: {
|
|||||||
return {
|
return {
|
||||||
hopperPath: installed.launcherPath,
|
hopperPath: installed.launcherPath,
|
||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
|
...(warning === "" ? {} : { warning }),
|
||||||
selectedClients: await filterClientsNeedingConfigure(
|
selectedClients: await filterClientsNeedingConfigure(
|
||||||
input.host,
|
input.host,
|
||||||
input.selectedClients,
|
input.selectedClients,
|
||||||
|
|||||||
@@ -203,18 +203,29 @@ export const systemSetupHost = (
|
|||||||
return {
|
return {
|
||||||
status: "installed",
|
status: "installed",
|
||||||
launcherPath: result.launcherPath,
|
launcherPath: result.launcherPath,
|
||||||
|
...(result.openFailure === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
|
||||||
|
}),
|
||||||
...(result.cleanupFailure === undefined
|
...(result.cleanupFailure === undefined
|
||||||
? {}
|
? {}
|
||||||
: { cleanupFailure: result.cleanupFailure }),
|
: { cleanupFailure: result.cleanupFailure }),
|
||||||
};
|
};
|
||||||
const failure = setupInstallFailure(result.reason);
|
const failure = setupInstallFailure(result.reason);
|
||||||
if (result.cleanupFailure === undefined) return failure;
|
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
||||||
return failure.status === "failed"
|
return {
|
||||||
? {
|
...failure,
|
||||||
...failure,
|
remediation: [
|
||||||
remediation: `${failure.remediation} ${result.cleanupFailure}`,
|
failure.remediation,
|
||||||
}
|
...(diagnostic === undefined
|
||||||
: failure;
|
? []
|
||||||
|
: [`Installer detail: ${diagnostic}`]),
|
||||||
|
...(result.cleanupFailure === undefined
|
||||||
|
? []
|
||||||
|
: [result.cleanupFailure]),
|
||||||
|
].join(" "),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
||||||
supportedClients: () =>
|
supportedClients: () =>
|
||||||
@@ -248,6 +259,21 @@ export const systemSetupHost = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setupInstallerDiagnostic = (
|
||||||
|
value: string | undefined,
|
||||||
|
): string | undefined => {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const normalized = value
|
||||||
|
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
|
||||||
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
if (normalized === "") return undefined;
|
||||||
|
return normalized.length <= 400
|
||||||
|
? normalized
|
||||||
|
: `${normalized.slice(0, 397).trimEnd()}...`;
|
||||||
|
};
|
||||||
|
|
||||||
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
const clientConfigurationOperations = (resources: ArtifactResourceScope) => ({
|
||||||
configureClient: (
|
configureClient: (
|
||||||
client: SetupClient,
|
client: SetupClient,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type SetupHopperInstallResult =
|
|||||||
readonly status: "installed";
|
readonly status: "installed";
|
||||||
readonly launcherPath: string;
|
readonly launcherPath: string;
|
||||||
readonly cleanupFailure?: string;
|
readonly cleanupFailure?: string;
|
||||||
|
readonly warning?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly status: "failed";
|
readonly status: "failed";
|
||||||
@@ -30,6 +31,11 @@ export type SetupHopperInstallResult =
|
|||||||
readonly remediation: string;
|
readonly remediation: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SetupHopperInstallFailure = Extract<
|
||||||
|
SetupHopperInstallResult,
|
||||||
|
{ readonly status: "failed" }
|
||||||
|
>;
|
||||||
|
|
||||||
export type HopperInstallFailureReason =
|
export type HopperInstallFailureReason =
|
||||||
| "unsupported_host"
|
| "unsupported_host"
|
||||||
| "release_metadata"
|
| "release_metadata"
|
||||||
@@ -93,7 +99,7 @@ const packageManagerRemediation = (
|
|||||||
export const setupInstallFailure = (
|
export const setupInstallFailure = (
|
||||||
reason: HopperInstallFailureReason,
|
reason: HopperInstallFailureReason,
|
||||||
packageFailure?: LinuxPackageInstallFailure,
|
packageFailure?: LinuxPackageInstallFailure,
|
||||||
): SetupHopperInstallResult => {
|
): SetupHopperInstallFailure => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case "unsupported_host":
|
case "unsupported_host":
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { inspectAnalysisView } from "../analysisView/AnalysisViewService.js";
|
||||||
|
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
|
||||||
|
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||||
|
import {
|
||||||
|
createEvidence,
|
||||||
|
parseEvidence,
|
||||||
|
type Evidence,
|
||||||
|
} from "../../domain/evidence.js";
|
||||||
|
import {
|
||||||
|
EvidenceIntegrityError,
|
||||||
|
EvidenceReferenceError,
|
||||||
|
} from "../../domain/evidenceErrors.js";
|
||||||
|
import { resolveArtifactComparisonEvidence } from "./ArtifactComparisonEvidence.js";
|
||||||
|
|
||||||
|
const artifactSummary = () => {
|
||||||
|
const parent = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||||
|
const summary = inspectAnalysisView(
|
||||||
|
{
|
||||||
|
source: { kind: "inline", evidence: parent },
|
||||||
|
view: { kind: "summary" },
|
||||||
|
},
|
||||||
|
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
|
||||||
|
);
|
||||||
|
if (!summary.ok) throw summary.error;
|
||||||
|
return { parent, summary: summary.value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const recreateSummary = (
|
||||||
|
summary: Evidence,
|
||||||
|
normalizedResult: Evidence["normalized_result"],
|
||||||
|
parameters: Evidence["parameters"] = summary.parameters,
|
||||||
|
): Evidence => {
|
||||||
|
if (summary.subject === null)
|
||||||
|
throw new Error("artifact summary fixture must identify its subject");
|
||||||
|
return createEvidence(
|
||||||
|
{
|
||||||
|
path: summary.subject.local_path,
|
||||||
|
sha256: summary.subject.digest.sha256,
|
||||||
|
format: summary.subject.format,
|
||||||
|
...(summary.subject.architecture === null
|
||||||
|
? {}
|
||||||
|
: { architecture: summary.subject.architecture }),
|
||||||
|
},
|
||||||
|
summary.provider,
|
||||||
|
{
|
||||||
|
predicateType: summary.predicate_type,
|
||||||
|
operation: summary.operation,
|
||||||
|
parameters,
|
||||||
|
result: normalizedResult,
|
||||||
|
...(summary.analysis_profile === null
|
||||||
|
? {}
|
||||||
|
: { analysisProfile: summary.analysis_profile }),
|
||||||
|
rawResult: summary.raw_result,
|
||||||
|
confidence: summary.confidence,
|
||||||
|
authority: summary.authority,
|
||||||
|
environment: summary.environment,
|
||||||
|
limitations: summary.limitations,
|
||||||
|
...(summary.relationship_coverage === null
|
||||||
|
? {}
|
||||||
|
: { relationshipCoverage: summary.relationship_coverage }),
|
||||||
|
locations: summary.locations,
|
||||||
|
evidenceLinks: summary.evidence_links,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("reports a missing retained parent instead of treating summary facts as inventory", () => {
|
||||||
|
const { summary } = artifactSummary();
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(summary, undefined);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
|
||||||
|
expect(resolved.error).toMatchObject({ reason: "missing" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses retained Evidence resolution to reject a mismatched parent identity", () => {
|
||||||
|
const { summary } = artifactSummary();
|
||||||
|
const otherParent = ARTIFACT_COMPARISON_EXAMPLE.right;
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(
|
||||||
|
summary,
|
||||||
|
() => otherParent,
|
||||||
|
);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
expect(resolved.error).toBeInstanceOf(EvidenceReferenceError);
|
||||||
|
expect(resolved.error).toMatchObject({ reason: "identity_mismatch" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a summary that declares the wrong parent operation", () => {
|
||||||
|
const { parent, summary } = artifactSummary();
|
||||||
|
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
|
||||||
|
summary.normalized_result,
|
||||||
|
);
|
||||||
|
const normalizedResult = {
|
||||||
|
...summaryResult,
|
||||||
|
parent_operation: "inspect_artifact",
|
||||||
|
};
|
||||||
|
const forged = recreateSummary(summary, normalizedResult);
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||||
|
evidenceId === parent.evidence_id ? parent : undefined,
|
||||||
|
);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (!resolved.ok)
|
||||||
|
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects summary parameters that do not identify the projected parent", () => {
|
||||||
|
const { parent, summary } = artifactSummary();
|
||||||
|
const forged = recreateSummary(summary, summary.normalized_result, {
|
||||||
|
...summary.parameters,
|
||||||
|
parent_evidence_id: `ev_${"f".repeat(64)}`,
|
||||||
|
});
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||||
|
evidenceId === parent.evidence_id ? parent : undefined,
|
||||||
|
);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (!resolved.ok)
|
||||||
|
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects summary facts that do not reproject from the complete parent", () => {
|
||||||
|
const { parent, summary } = artifactSummary();
|
||||||
|
const summaryResult = artifactInventorySummaryViewResultSchema.parse(
|
||||||
|
summary.normalized_result,
|
||||||
|
);
|
||||||
|
const normalizedResult = {
|
||||||
|
...summaryResult,
|
||||||
|
summary: {
|
||||||
|
...summaryResult.summary,
|
||||||
|
available_counts: {
|
||||||
|
...summaryResult.summary.available_counts,
|
||||||
|
nodes: summaryResult.summary.available_counts.nodes + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const forged = recreateSummary(summary, normalizedResult);
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(forged, (evidenceId) =>
|
||||||
|
evidenceId === parent.evidence_id ? parent : undefined,
|
||||||
|
);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (!resolved.ok)
|
||||||
|
expect(resolved.error).toBeInstanceOf(EvidenceIntegrityError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts display-only subject changes excluded from Evidence identity", () => {
|
||||||
|
const { parent, summary } = artifactSummary();
|
||||||
|
if (summary.subject === null)
|
||||||
|
throw new Error("artifact summary fixture must identify its subject");
|
||||||
|
const displayOnly = parseEvidence({
|
||||||
|
...summary,
|
||||||
|
subject: {
|
||||||
|
...summary.subject,
|
||||||
|
name: "renamed.app",
|
||||||
|
local_path: "/display-only/renamed.app",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resolved = resolveArtifactComparisonEvidence(
|
||||||
|
displayOnly,
|
||||||
|
(evidenceId) => (evidenceId === parent.evidence_id ? parent : undefined),
|
||||||
|
);
|
||||||
|
expect(resolved).toEqual({ ok: true, value: parent });
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { EvidenceLookup } from "../EvidenceInputResolver.js";
|
||||||
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
|
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||||
|
import { inspectAnalysisViewValidated } from "../analysisView/AnalysisViewService.js";
|
||||||
|
import { artifactInventorySummaryViewResultSchema } from "../../domain/analysisView/analysisView.js";
|
||||||
|
import {
|
||||||
|
EvidenceIntegrityError,
|
||||||
|
EvidenceReferenceError,
|
||||||
|
} from "../../domain/evidenceErrors.js";
|
||||||
|
import type { Evidence } from "../../domain/evidence.js";
|
||||||
|
import { err, ok, type Result } from "../../domain/result.js";
|
||||||
|
|
||||||
|
/** Resolve only an artifact summary's exact retained owner before comparison. */
|
||||||
|
export const resolveArtifactComparisonEvidence = (
|
||||||
|
evidence: Evidence,
|
||||||
|
evidenceById: EvidenceLookup | undefined,
|
||||||
|
): Result<Evidence, AnalysisError> => {
|
||||||
|
if (evidence.operation !== "inspect_analysis_view") return ok(evidence);
|
||||||
|
|
||||||
|
const summary = artifactInventorySummaryViewResultSchema.safeParse(
|
||||||
|
evidence.normalized_result,
|
||||||
|
);
|
||||||
|
if (!summary.success)
|
||||||
|
return err(
|
||||||
|
new AnalysisInputError("compare_artifacts", undefined, [
|
||||||
|
{
|
||||||
|
path: ["evidence"],
|
||||||
|
reason: "invalid_value",
|
||||||
|
message:
|
||||||
|
"Artifact comparison accepts only an artifact inventory summary from inspect_analysis_view.",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentId = summary.data.parent_evidence_id;
|
||||||
|
const parent = evidenceById?.(parentId);
|
||||||
|
if (parent === undefined)
|
||||||
|
return err(
|
||||||
|
new EvidenceReferenceError(
|
||||||
|
parentId,
|
||||||
|
"missing",
|
||||||
|
"retained complete artifact inventory Evidence",
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const reprojected = inspectAnalysisViewValidated(
|
||||||
|
{
|
||||||
|
source: { kind: "retained-evidence", evidence_id: parentId },
|
||||||
|
view: { kind: "summary" },
|
||||||
|
},
|
||||||
|
(evidenceId) => (evidenceId === parentId ? parent : undefined),
|
||||||
|
);
|
||||||
|
if (!reprojected.ok) return reprojected;
|
||||||
|
if (reprojected.value.evidence_id !== evidence.evidence_id)
|
||||||
|
return err(
|
||||||
|
new EvidenceIntegrityError(
|
||||||
|
"Artifact summary does not match its retained parent inventory.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return ok(parent);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||||
|
import type { RelationshipCoverage } from "../../domain/relationshipCoverage.js";
|
||||||
import {
|
import {
|
||||||
ANALYSIS_SNAPSHOT_PROFILE,
|
ANALYSIS_SNAPSHOT_PROFILE,
|
||||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||||
@@ -502,57 +503,58 @@ describe("analysis snapshot cache partitioning", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("relationship coverage snapshot replay", () => {
|
const createRelationshipCoverageSnapshot = (
|
||||||
it("authenticates relationship coverage and replays legacy reads as unknown", () => {
|
relationshipCoverage?: RelationshipCoverage,
|
||||||
const cache = new AnalysisSnapshotCache();
|
) => {
|
||||||
const operation = "procedure_callees" as const;
|
const operation = "procedure_callees" as const;
|
||||||
const parameters = { procedure: "0x1000", document: "fixture" };
|
const parameters = { procedure: "0x1000", document: "fixture" };
|
||||||
const coverage = {
|
const cache = new AnalysisSnapshotCache();
|
||||||
status: "complete" as const,
|
const execution = createAnalysisExecution(
|
||||||
diagnostics: ["The provider retained an unresolved call target."],
|
["0x2000"],
|
||||||
};
|
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||||
const execution = createAnalysisExecution(
|
{
|
||||||
["0x2000"],
|
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
rawResult: ["0x2000"],
|
||||||
{
|
subject: ANALYSIS_SNAPSHOT_TARGET,
|
||||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
|
||||||
rawResult: ["0x2000"],
|
},
|
||||||
relationshipCoverage: coverage,
|
);
|
||||||
subject: {
|
cache.record({
|
||||||
path: ANALYSIS_SNAPSHOT_TARGET.path,
|
target: ANALYSIS_SNAPSHOT_TARGET,
|
||||||
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
|
profile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||||
format: ANALYSIS_SNAPSHOT_TARGET.format,
|
operation,
|
||||||
...(ANALYSIS_SNAPSHOT_TARGET.architecture === undefined
|
parameters,
|
||||||
? {}
|
execution,
|
||||||
: { architecture: ANALYSIS_SNAPSHOT_TARGET.architecture }),
|
});
|
||||||
},
|
const evidence = createEvidence(
|
||||||
},
|
ANALYSIS_SNAPSHOT_TARGET,
|
||||||
);
|
ANALYSIS_SNAPSHOT_PROVIDER,
|
||||||
cache.record({
|
{
|
||||||
target: ANALYSIS_SNAPSHOT_TARGET,
|
|
||||||
profile: ANALYSIS_SNAPSHOT_PROFILE,
|
|
||||||
operation,
|
operation,
|
||||||
parameters,
|
parameters,
|
||||||
execution,
|
result: ["0x2000"],
|
||||||
});
|
rawResult: ["0x2000"],
|
||||||
const evidence = createEvidence(
|
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
||||||
ANALYSIS_SNAPSHOT_TARGET,
|
...(relationshipCoverage === undefined ? {} : { relationshipCoverage }),
|
||||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
},
|
||||||
{
|
);
|
||||||
operation,
|
const exported = cache.export(
|
||||||
parameters,
|
ANALYSIS_SNAPSHOT_TARGET,
|
||||||
result: ["0x2000"],
|
ANALYSIS_SNAPSHOT_PROFILE,
|
||||||
rawResult: ["0x2000"],
|
createEvidenceBundle([evidence]),
|
||||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
);
|
||||||
relationshipCoverage: coverage,
|
if (!exported.ok) throw exported.error;
|
||||||
},
|
return { cache, operation, parameters, snapshot: exported.value };
|
||||||
);
|
};
|
||||||
const exported = cache.export(
|
|
||||||
ANALYSIS_SNAPSHOT_TARGET,
|
describe("relationship coverage snapshot replay", () => {
|
||||||
ANALYSIS_SNAPSHOT_PROFILE,
|
it("authenticates coverage and rejects missing serialized fields", () => {
|
||||||
createEvidenceBundle([evidence]),
|
const coverage: RelationshipCoverage = {
|
||||||
);
|
status: "complete",
|
||||||
if (!exported.ok) throw exported.error;
|
diagnostics: ["The provider retained an unresolved call target."],
|
||||||
|
};
|
||||||
|
const { cache, operation, parameters, snapshot } =
|
||||||
|
createRelationshipCoverageSnapshot(coverage);
|
||||||
expect(
|
expect(
|
||||||
cache.lookup(
|
cache.lookup(
|
||||||
ANALYSIS_SNAPSHOT_TARGET,
|
ANALYSIS_SNAPSHOT_TARGET,
|
||||||
@@ -562,7 +564,7 @@ describe("relationship coverage snapshot replay", () => {
|
|||||||
)?.relationshipCoverage,
|
)?.relationshipCoverage,
|
||||||
).toEqual(coverage);
|
).toEqual(coverage);
|
||||||
|
|
||||||
const tampered = structuredClone(exported.value);
|
const tampered = structuredClone(snapshot);
|
||||||
const tamperedEntry = tampered.entries[0];
|
const tamperedEntry = tampered.entries[0];
|
||||||
if (tamperedEntry === undefined) throw new Error("snapshot entry missing");
|
if (tamperedEntry === undefined) throw new Error("snapshot entry missing");
|
||||||
tamperedEntry.execution.relationship_coverage = {
|
tamperedEntry.execution.relationship_coverage = {
|
||||||
@@ -573,26 +575,36 @@ describe("relationship coverage snapshot replay", () => {
|
|||||||
new AnalysisSnapshotCache().import(tampered, undefined, () => ok(0)).ok,
|
new AnalysisSnapshotCache().import(tampered, undefined, () => ok(0)).ok,
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
const legacy = structuredClone(exported.value);
|
const missingCoverage = structuredClone(snapshot);
|
||||||
const legacyEntry = legacy.entries[0];
|
const missingCoverageEntry = missingCoverage.entries[0];
|
||||||
if (legacyEntry === undefined) throw new Error("snapshot entry missing");
|
if (missingCoverageEntry === undefined)
|
||||||
Reflect.deleteProperty(legacyEntry.execution, "relationship_coverage");
|
throw new Error("snapshot entry missing");
|
||||||
const legacyEvidence = createEvidence(
|
Reflect.deleteProperty(
|
||||||
ANALYSIS_SNAPSHOT_TARGET,
|
missingCoverageEntry.execution,
|
||||||
ANALYSIS_SNAPSHOT_PROVIDER,
|
"relationship_coverage",
|
||||||
{
|
|
||||||
operation,
|
|
||||||
parameters,
|
|
||||||
result: ["0x2000"],
|
|
||||||
rawResult: ["0x2000"],
|
|
||||||
analysisProfile: ANALYSIS_SNAPSHOT_PROFILE,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
Reflect.deleteProperty(legacyEvidence, "relationship_coverage");
|
const missingCoverageEvidence = missingCoverage.evidence_bundle.records[0];
|
||||||
legacy.evidence_bundle = createEvidenceBundle([legacyEvidence]);
|
if (missingCoverageEvidence === undefined)
|
||||||
parseAnalysisSnapshot(legacy);
|
throw new Error("snapshot Evidence missing");
|
||||||
|
Reflect.deleteProperty(missingCoverageEvidence, "relationship_coverage");
|
||||||
|
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
|
||||||
|
expect(
|
||||||
|
new AnalysisSnapshotCache().import(missingCoverage, undefined, () =>
|
||||||
|
ok(0),
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays absent coverage as explicitly unknown", () => {
|
||||||
|
const { operation, parameters, snapshot } =
|
||||||
|
createRelationshipCoverageSnapshot();
|
||||||
|
expect(snapshot.entries[0]?.execution.relationship_coverage).toBeNull();
|
||||||
|
expect(
|
||||||
|
snapshot.evidence_bundle.records[0]?.relationship_coverage,
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
const replayCache = new AnalysisSnapshotCache();
|
const replayCache = new AnalysisSnapshotCache();
|
||||||
expect(replayCache.import(legacy, undefined, () => ok(0)).ok).toBe(true);
|
expect(replayCache.import(snapshot, undefined, () => ok(0)).ok).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
replayCache.lookup(
|
replayCache.lookup(
|
||||||
ANALYSIS_SNAPSHOT_TARGET,
|
ANALYSIS_SNAPSHOT_TARGET,
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ export const addJavaScriptSourceModules = (
|
|||||||
label: file.path,
|
label: file.path,
|
||||||
properties: {
|
properties: {
|
||||||
semantic_role: "source-module",
|
semantic_role: "source-module",
|
||||||
|
source_sha256: file.sha256,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
container_sha256: file.container_sha256,
|
|
||||||
logical_module_key: file.path,
|
logical_module_key: file.path,
|
||||||
module_format: moduleFormat(file.path, semantic.ir),
|
module_format: moduleFormat(file.path, semantic.ir),
|
||||||
import_relationships: semantic.ir.moduleLinks.filter(
|
import_relationships: semantic.ir.moduleLinks.filter(
|
||||||
|
|||||||
@@ -421,6 +421,33 @@ it("keeps duplicate function fingerprints ambiguous", () => {
|
|||||||
expect(query.summary.total_seed_matches).toBe(2);
|
expect(query.summary.total_seed_matches).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects analyzer-produced boundaries by their method field", () => {
|
||||||
|
const graph = graphFor("const port = Number(process.env.PORT);");
|
||||||
|
const boundary = graph.nodes.find(({ kind }) => kind === "boundary");
|
||||||
|
if (boundary === undefined) throw new Error("Expected a boundary node");
|
||||||
|
|
||||||
|
expect(boundary.properties).toMatchObject({
|
||||||
|
boundary_kind: "coerce",
|
||||||
|
field: "Number",
|
||||||
|
method: "Number",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
queryJavaScriptSemanticGraph(graph, {
|
||||||
|
seed: { kind: "boundary-field", field: "Number" },
|
||||||
|
direction: "forward-influence",
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
seed_node_ids: [boundary.node_id],
|
||||||
|
summary: { total_seed_matches: 1 },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
queryJavaScriptSemanticGraph(graph, {
|
||||||
|
seed: { kind: "boundary-field", field: "missing-field" },
|
||||||
|
direction: "forward-influence",
|
||||||
|
}).summary.total_seed_matches,
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
const semanticNodeFor = (roleKey: string) => {
|
const semanticNodeFor = (roleKey: string) => {
|
||||||
const file: JavaScriptArtifactFile = {
|
const file: JavaScriptArtifactFile = {
|
||||||
path: "budget.js",
|
path: "budget.js",
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ const addBoundaryNode = (
|
|||||||
null),
|
null),
|
||||||
properties: {
|
properties: {
|
||||||
boundary_kind: operation.kind,
|
boundary_kind: operation.kind,
|
||||||
|
field: operation.method,
|
||||||
method: operation.method,
|
method: operation.method,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,24 @@ import type {
|
|||||||
ExecutionOptions,
|
ExecutionOptions,
|
||||||
ProviderAvailability,
|
ProviderAvailability,
|
||||||
} from "../AnalysisProvider.js";
|
} from "../AnalysisProvider.js";
|
||||||
import type { JebRequest } from "../../domain/jeb/jebAnalysis.js";
|
import type { JebOperation, JebRequest } from "../../domain/jeb/jebAnalysis.js";
|
||||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||||
import type { Result } from "../../domain/result.js";
|
import type { Result } from "../../domain/result.js";
|
||||||
|
|
||||||
|
/** Live engine readiness and the operations advertised by that engine. */
|
||||||
|
export type JebAnalysisAvailability =
|
||||||
|
| (Extract<ProviderAvailability, { status: "available" }> & {
|
||||||
|
readonly supportedOperations: readonly JebOperation[];
|
||||||
|
})
|
||||||
|
| Extract<ProviderAvailability, { status: "unavailable" }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JEB inspection boundary served by a caller-supplied running engine client.
|
* JEB inspection boundary served by a caller-supplied running engine client.
|
||||||
* Implementation details belong to the provider adapter.
|
* Implementation details belong to the provider adapter.
|
||||||
*/
|
*/
|
||||||
export interface JebAnalysisPort {
|
export interface JebAnalysisPort {
|
||||||
/** Probe the running JEB client without opening a project. */
|
/** Probe the running JEB client without opening a project. */
|
||||||
inspectAvailability(signal?: AbortSignal): Promise<ProviderAvailability>;
|
inspectAvailability(signal?: AbortSignal): Promise<JebAnalysisAvailability>;
|
||||||
/** Release engine connection state; the JEB client itself keeps running. */
|
/** Release engine connection state; the JEB client itself keeps running. */
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
execute(
|
execute(
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const fakePort = (
|
|||||||
code: null,
|
code: null,
|
||||||
reason: null,
|
reason: null,
|
||||||
diagnostics: {},
|
diagnostics: {},
|
||||||
|
supportedOperations: [
|
||||||
|
"inspect_jeb_client",
|
||||||
|
"list_jeb_units",
|
||||||
|
"open_jeb_project",
|
||||||
|
"decompile_jeb_item",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async close() {},
|
async close() {},
|
||||||
|
|||||||
@@ -460,12 +460,26 @@ describe("Mach-O load command reader inputs", () => {
|
|||||||
"i386",
|
"i386",
|
||||||
);
|
);
|
||||||
// A Java class file shares the FAT magic; version fields are not a slice count.
|
// A Java class file shares the FAT magic; version fields are not a slice count.
|
||||||
const javaClasses = [javaClassFile(), javaClassFile(4000)];
|
const javaClasses = [
|
||||||
expect(javaClasses.map(({ length }) => length)).toEqual([57, 4060]);
|
javaClassFile(),
|
||||||
|
javaClassFile(4000),
|
||||||
|
javaClassFile(4000, true),
|
||||||
|
];
|
||||||
for (const javaClass of javaClasses) {
|
for (const javaClass of javaClasses) {
|
||||||
expect(hasMachoMagic(javaClass)).toBe(true);
|
expect(hasMachoMagic(javaClass)).toBe(true);
|
||||||
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
|
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
|
||||||
}
|
}
|
||||||
|
const malformedFat = Buffer.alloc(28);
|
||||||
|
malformedFat.writeUInt32BE(0xcafebabe, 0);
|
||||||
|
malformedFat.writeUInt32BE(1, 4);
|
||||||
|
malformedFat.writeUInt32BE(0x0100000c, 8);
|
||||||
|
malformedFat.writeUInt32BE(4096, 16);
|
||||||
|
malformedFat.writeUInt32BE(32, 20);
|
||||||
|
malformedFat.writeUInt32BE(12, 24);
|
||||||
|
expect(await read(malformedFat)).toMatchObject({
|
||||||
|
status: "malformed",
|
||||||
|
reason: expect.stringContaining("slice range extends beyond the file"),
|
||||||
|
});
|
||||||
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
|
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
|
||||||
status: "not-mach-o",
|
status: "not-mach-o",
|
||||||
});
|
});
|
||||||
@@ -477,6 +491,35 @@ describe("Mach-O load command reader inputs", () => {
|
|||||||
await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])),
|
await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])),
|
||||||
).toEqual({ status: "malformed", reason: "Mach-O header is truncated" });
|
).toEqual({ status: "malformed", reason: "Mach-O header is truncated" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps oversized CAFEBABE identity unknown when class payload is sparse", async () => {
|
||||||
|
const classPrefix = Buffer.from(javaClassFile(4000, true));
|
||||||
|
const payloadBytes = MAX_LOAD_COMMAND_BYTES + 1;
|
||||||
|
const attributeHeader = Buffer.alloc(6);
|
||||||
|
attributeHeader.writeUInt16BE(5, 0);
|
||||||
|
attributeHeader.writeUInt32BE(payloadBytes, 2);
|
||||||
|
classPrefix.writeUInt16BE(1, classPrefix.length - 2);
|
||||||
|
const prefix = Buffer.concat([classPrefix, attributeHeader]);
|
||||||
|
const size = prefix.length + payloadBytes;
|
||||||
|
const sparseReadAt = async (offset: number, length: number) => {
|
||||||
|
const result = Buffer.alloc(Math.min(length, size - offset));
|
||||||
|
if (offset < prefix.length)
|
||||||
|
prefix.copy(
|
||||||
|
result,
|
||||||
|
0,
|
||||||
|
offset,
|
||||||
|
Math.min(prefix.length, offset + length),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(await readMachoImage(sparseReadAt, size)).toEqual({
|
||||||
|
status: "unsupported",
|
||||||
|
reason: expect.stringContaining(
|
||||||
|
"class identity scan exceeds the 16 MiB limit",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Mach-O load command reader failures", () => {
|
describe("Mach-O load command reader failures", () => {
|
||||||
|
|||||||
@@ -141,7 +141,14 @@ export const readMachoImage = async (
|
|||||||
: { status: "not-mach-o" };
|
: { status: "not-mach-o" };
|
||||||
const view = viewOf(head);
|
const view = viewOf(head);
|
||||||
const magic = view.getUint32(0, false);
|
const magic = view.getUint32(0, false);
|
||||||
let header = readFatContainerHeader(head, size);
|
let header: ReturnType<typeof readFatContainerHeader>;
|
||||||
|
if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) {
|
||||||
|
const complete = await readAt(0, size);
|
||||||
|
header =
|
||||||
|
complete.byteLength === size
|
||||||
|
? readFatContainerHeader(complete, size)
|
||||||
|
: readFatContainerHeader(head, size);
|
||||||
|
} else header = readFatContainerHeader(head, size);
|
||||||
if (header.state !== "not-fat") {
|
if (header.state !== "not-fat") {
|
||||||
if (header.state === "needs-data") {
|
if (header.state === "needs-data") {
|
||||||
header = readFatContainerHeader(
|
header = readFatContainerHeader(
|
||||||
@@ -169,8 +176,17 @@ export const readMachoImage = async (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (identity.state === "not-mach-o") return { status: "not-mach-o" };
|
if (identity.state === "not-mach-o") return { status: "not-mach-o" };
|
||||||
if (identity.state === "malformed")
|
if (identity.state === "malformed") {
|
||||||
|
// A large CAFEBABE input whose bounded prefix resembles an invalid
|
||||||
|
// known-CPU FAT table remains unknown until its JVM identity can be
|
||||||
|
// checked; do not turn the resource limit into a malformed claim.
|
||||||
|
if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES)
|
||||||
|
throw new MachoFormatIssue(
|
||||||
|
"unsupported",
|
||||||
|
"FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit",
|
||||||
|
);
|
||||||
throw new MachoFormatIssue(identity.category, identity.reason);
|
throw new MachoFormatIssue(identity.category, identity.reason);
|
||||||
|
}
|
||||||
if (identity.state === "needs-slice-header")
|
if (identity.state === "needs-slice-header")
|
||||||
return { status: "not-mach-o" };
|
return { status: "not-mach-o" };
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { realpath } from "node:fs/promises";
|
import { realpath } from "node:fs/promises";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
artifactInventoryOutputSchema,
|
artifactInventoryResultSchema,
|
||||||
type ArtifactInventoryOutput,
|
type ArtifactInventoryResult,
|
||||||
} from "../../domain/artifactGraph.js";
|
} from "../../domain/artifactGraph.js";
|
||||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||||
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
|
import { scanCanonicalArtifactInventoryInScope } from "./scanCanonical.js";
|
||||||
@@ -13,9 +13,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySn
|
|||||||
export const inventoryArtifact = async (
|
export const inventoryArtifact = async (
|
||||||
inputPath: string,
|
inputPath: string,
|
||||||
options: ArtifactInventoryOptions,
|
options: ArtifactInventoryOptions,
|
||||||
): Promise<ArtifactInventoryOutput> => {
|
): Promise<ArtifactInventoryResult> => {
|
||||||
const snapshot = await scanArtifactInventory(inputPath, options);
|
const snapshot = await scanArtifactInventory(inputPath, options);
|
||||||
return artifactInventoryOutputSchema.parse(snapshot);
|
return artifactInventoryResultSchema.parse(snapshot);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Scan an artifact once and retain the complete immutable graph for projection. */
|
/** Scan an artifact once and retain the complete immutable graph for projection. */
|
||||||
|
|||||||
@@ -289,7 +289,6 @@ const buildInventorySnapshot = async (
|
|||||||
provenance: reader?.provenance() ?? [],
|
provenance: reader?.provenance() ?? [],
|
||||||
integrity_contradictions: integrityContradictions,
|
integrity_contradictions: integrityContradictions,
|
||||||
coverage,
|
coverage,
|
||||||
coverage_status: coverage.status === "complete" ? "complete" : "partial",
|
|
||||||
limitations: buildLimitations(
|
limitations: buildLimitations(
|
||||||
[...traversal.limitations, ...edgeResult.limitations],
|
[...traversal.limitations, ...edgeResult.limitations],
|
||||||
integrityContradictions,
|
integrityContradictions,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||||
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
|
||||||
|
|
||||||
const secret = (secret_id: string) => ({ source: "secret", secret_id });
|
const secret = (secret_id: string) => ({ source: "secret", secret_id });
|
||||||
const literal = (value: string) => ({
|
const literal = (value: string) => ({
|
||||||
@@ -136,6 +137,7 @@ describe("browserScenarioSchema", () => {
|
|||||||
service_workers: "block",
|
service_workers: "block",
|
||||||
});
|
});
|
||||||
expect(parsed.capture).toEqual({
|
expect(parsed.capture).toEqual({
|
||||||
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
network: {
|
network: {
|
||||||
request_body: false,
|
request_body: false,
|
||||||
response_body: false,
|
response_body: false,
|
||||||
@@ -145,7 +147,9 @@ describe("browserScenarioSchema", () => {
|
|||||||
at_end: ["dom", "storage"],
|
at_end: ["dom", "storage"],
|
||||||
events: ["console", "page-errors", "network", "websockets"],
|
events: ["console", "page-errors", "network", "websockets"],
|
||||||
});
|
});
|
||||||
expect(parsed).not.toHaveProperty("limits");
|
expect(parsed.capture.limits).toEqual(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows callers to choose visible browsers and service workers", () => {
|
it("allows callers to choose visible browsers and service workers", () => {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const browserScenarioCaptureData = (input: {
|
|||||||
.sort(),
|
.sort(),
|
||||||
network_content: input.scenario.capture.network,
|
network_content: input.scenario.capture.network,
|
||||||
event_families: [...new Set(input.scenario.capture.events)].sort(),
|
event_families: [...new Set(input.scenario.capture.events)].sort(),
|
||||||
|
limits: input.scenario.capture.limits,
|
||||||
},
|
},
|
||||||
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
|
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
|
||||||
steps: [...input.steps],
|
steps: [...input.steps],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { BrowserScenario } from "../domain/browserScenario.js";
|
|||||||
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
|
import type { BrowserScenarioAction } from "../domain/browserScenarioValues.js";
|
||||||
import type {
|
import type {
|
||||||
BrowserScenarioEvent,
|
BrowserScenarioEvent,
|
||||||
|
BrowserScenarioEventOmissions,
|
||||||
BrowserStepArtifacts,
|
BrowserStepArtifacts,
|
||||||
} from "../domain/browserScenarioCaptureValues.js";
|
} from "../domain/browserScenarioCaptureValues.js";
|
||||||
import type { SanitizedBrowserUrl } from "../domain/browserObservation.js";
|
import type { SanitizedBrowserUrl } from "../domain/browserObservation.js";
|
||||||
@@ -23,6 +24,7 @@ export interface BrowserScenarioSessionPort {
|
|||||||
readonly retained: number;
|
readonly retained: number;
|
||||||
readonly dropped: number;
|
readonly dropped: number;
|
||||||
readonly items: readonly BrowserScenarioEvent[];
|
readonly items: readonly BrowserScenarioEvent[];
|
||||||
|
readonly omissions: BrowserScenarioEventOmissions;
|
||||||
};
|
};
|
||||||
/** Gaps in selected event families that prevent complete capture claims. */
|
/** Gaps in selected event families that prevent complete capture claims. */
|
||||||
eventLimitations?(): readonly string[];
|
eventLimitations?(): readonly string[];
|
||||||
|
|||||||
@@ -73,7 +73,13 @@ import { comparePngScreenshots } from "./PngVisualDiff.js";
|
|||||||
|
|
||||||
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js";
|
||||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||||
const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const;
|
const CLEANUP_DOMAINS = [
|
||||||
|
"Network",
|
||||||
|
"Debugger",
|
||||||
|
"Runtime",
|
||||||
|
"Page",
|
||||||
|
"WebMCP",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
/** Passive browser observation through a selected user-owned CDP endpoint. */
|
||||||
export class CdpBrowserProvider implements BrowserObservationPort {
|
export class CdpBrowserProvider implements BrowserObservationPort {
|
||||||
|
|||||||
@@ -145,15 +145,16 @@ class FakeSession implements BrowserScenarioSessionPort {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
events(): {
|
events(): ReturnType<BrowserScenarioSessionPort["events"]> {
|
||||||
readonly retained: number;
|
|
||||||
readonly dropped: number;
|
|
||||||
readonly items: readonly BrowserScenarioEvent[];
|
|
||||||
} {
|
|
||||||
return {
|
return {
|
||||||
retained: this.eventItems.length,
|
retained: this.eventItems.length,
|
||||||
dropped: 0,
|
dropped: 0,
|
||||||
items: this.eventItems,
|
items: this.eventItems,
|
||||||
|
omissions: {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,9 +560,16 @@ describe("PlaywrightBrowserScenarioProvider operation failures", () => {
|
|||||||
},
|
},
|
||||||
steps: [{ step_id: "scenario_start" }],
|
steps: [{ step_id: "scenario_start" }],
|
||||||
events: {
|
events: {
|
||||||
|
retained: 1,
|
||||||
|
dropped: 0,
|
||||||
items: [
|
items: [
|
||||||
expect.objectContaining({ text: "navigation started" }),
|
expect.objectContaining({ text: "navigation started" }),
|
||||||
],
|
],
|
||||||
|
omissions: {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { Page } from "playwright-core";
|
import type { BrowserContext, Page, Request, Response } from "playwright-core";
|
||||||
|
|
||||||
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
import { browserScenarioSchema } from "../domain/browserScenario.js";
|
||||||
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
import { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||||
@@ -76,7 +76,124 @@ describe("declared secrets in scenario events", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const captureNetworkBudgetFixture = async (
|
||||||
|
maxTotalEventAllocationBytes?: number,
|
||||||
|
) => {
|
||||||
|
const pageListeners = new Map<string, (value: unknown) => void>();
|
||||||
|
const contextListeners = new Map<string, (value: unknown) => void>();
|
||||||
|
const context = {
|
||||||
|
on: (name: string, listener: (value: unknown) => void) =>
|
||||||
|
contextListeners.set(name, listener),
|
||||||
|
off: vi.fn(),
|
||||||
|
} as unknown as BrowserContext;
|
||||||
|
const page = {
|
||||||
|
url: () => "http://127.0.0.1/fixture",
|
||||||
|
context: () => context,
|
||||||
|
on: (name: string, listener: (value: unknown) => void) =>
|
||||||
|
pageListeners.set(name, listener),
|
||||||
|
off: vi.fn(),
|
||||||
|
} as unknown as Page;
|
||||||
|
const scenario = browserScenarioSchema.parse({
|
||||||
|
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
||||||
|
start_url: { url: "http://127.0.0.1/fixture" },
|
||||||
|
actions: [{ step_id: "ready", action: "wait_for_timeout", duration_ms: 1 }],
|
||||||
|
capture: {
|
||||||
|
events: ["network"],
|
||||||
|
network: { response_body: true },
|
||||||
|
...(maxTotalEventAllocationBytes === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
limits: {
|
||||||
|
max_total_event_allocation_bytes: maxTotalEventAllocationBytes,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const secrets = BrowserScenarioSecrets.resolve(scenario, {});
|
||||||
|
if (secrets === undefined) throw new Error("Expected resolved secrets");
|
||||||
|
const redactBytes = vi.spyOn(secrets, "redactBytes");
|
||||||
|
const capture = new PlaywrightScenarioEvents({
|
||||||
|
page,
|
||||||
|
context,
|
||||||
|
ownsContext: true,
|
||||||
|
enabled: new Set(["network"]),
|
||||||
|
network: scenario.capture.network,
|
||||||
|
limits: scenario.capture.limits,
|
||||||
|
secrets,
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 43; index += 1) {
|
||||||
|
const post = index >= 40;
|
||||||
|
const body = post
|
||||||
|
? Buffer.from('{"ok":true}')
|
||||||
|
: Buffer.from("x".repeat(60_000));
|
||||||
|
const request = {
|
||||||
|
method: () => (post ? "POST" : "GET"),
|
||||||
|
url: () => `http://127.0.0.1/${post ? "final" : "data"}/${String(index)}`,
|
||||||
|
resourceType: () => "fetch",
|
||||||
|
postDataBuffer: () => (post ? Buffer.from("{}") : null),
|
||||||
|
redirectedFrom: () => null,
|
||||||
|
failure: () => null,
|
||||||
|
headers: () => ({ "content-type": "application/json" }),
|
||||||
|
headersArray: async () => [
|
||||||
|
{ name: "content-type", value: "application/json" },
|
||||||
|
],
|
||||||
|
} as unknown as Request;
|
||||||
|
const response = {
|
||||||
|
request: () => request,
|
||||||
|
url: () => request.url(),
|
||||||
|
status: () => 200,
|
||||||
|
headers: () => ({ "content-type": "application/json" }),
|
||||||
|
headersArray: async () => [
|
||||||
|
{ name: "content-type", value: "application/json" },
|
||||||
|
],
|
||||||
|
body: async () => body,
|
||||||
|
} as unknown as Response;
|
||||||
|
contextListeners.get("request")?.(request);
|
||||||
|
contextListeners.get("response")?.(response);
|
||||||
|
contextListeners.get("requestfinished")?.(request);
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
await capture.finish();
|
||||||
|
return {
|
||||||
|
result: capture.result(),
|
||||||
|
redactedByteValues: redactBytes.mock.calls.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
describe("PlaywrightScenarioEvents", () => {
|
describe("PlaywrightScenarioEvents", () => {
|
||||||
|
it("identifies omitted network transaction content and honors a raised budget", async () => {
|
||||||
|
const baseline = await captureNetworkBudgetFixture();
|
||||||
|
const raised = await captureNetworkBudgetFixture(32 * 1024 * 1024);
|
||||||
|
expect(baseline.result.dropped).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
baseline.result.omissions.by_kind.find(
|
||||||
|
({ kind }) => kind === "network-content",
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
count: expect.any(Number),
|
||||||
|
reasons: expect.arrayContaining(["max-total-event-allocation-bytes"]),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
baseline.result.omissions.network_transactions.length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(baseline.redactedByteValues).toBe(
|
||||||
|
2 *
|
||||||
|
baseline.result.items.filter(
|
||||||
|
(event) => event.kind === "network-content",
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
expect(raised.result).toMatchObject({
|
||||||
|
dropped: 0,
|
||||||
|
omissions: { network_transactions: [] },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
raised.result.items.filter(
|
||||||
|
(event) =>
|
||||||
|
event.kind === "network-content" && event.phase === "response",
|
||||||
|
),
|
||||||
|
).toHaveLength(43);
|
||||||
|
});
|
||||||
|
|
||||||
it("observes requested popup page errors independently of popup lifecycle events", () => {
|
it("observes requested popup page errors independently of popup lifecycle events", () => {
|
||||||
const listeners = new Map<string, (value: unknown) => void>();
|
const listeners = new Map<string, (value: unknown) => void>();
|
||||||
const popupListeners = new Map<string, (value: unknown) => void>();
|
const popupListeners = new Map<string, (value: unknown) => void>();
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ import {
|
|||||||
} from "../domain/browserScenarioCaptureValues.js";
|
} from "../domain/browserScenarioCaptureValues.js";
|
||||||
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
import type { BrowserScenarioSecrets } from "./BrowserScenarioSecrets.js";
|
||||||
import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js";
|
import type { BrowserNetworkContentSelection } from "../domain/browserNetworkEvidence.js";
|
||||||
|
import type { BrowserNetworkHeaders } from "../domain/browserNetworkEvidence.js";
|
||||||
|
import type { BrowserScenarioCaptureLimits } from "../domain/browserScenarioCaptureLimits.js";
|
||||||
|
import type {
|
||||||
|
BrowserScenarioEventAdmission,
|
||||||
|
BrowserScenarioEventBudgetReason,
|
||||||
|
BrowserScenarioEventOmissions,
|
||||||
|
} from "../domain/browserScenarioCaptureValues.js";
|
||||||
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
|
||||||
import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js";
|
import { PlaywrightScenarioNetwork } from "./network/PlaywrightScenarioNetwork.js";
|
||||||
|
|
||||||
type EventName =
|
type EventName =
|
||||||
@@ -43,14 +51,12 @@ interface EventCaptureOptions {
|
|||||||
readonly enabled: ReadonlySet<EventName>;
|
readonly enabled: ReadonlySet<EventName>;
|
||||||
readonly secrets: BrowserScenarioSecrets;
|
readonly secrets: BrowserScenarioSecrets;
|
||||||
readonly network?: BrowserNetworkContentSelection;
|
readonly network?: BrowserNetworkContentSelection;
|
||||||
|
readonly limits?: BrowserScenarioCaptureLimits;
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conservative allocation accounting includes UTF-16 storage and worst-case
|
// Conservative allocation accounting includes UTF-16 storage and worst-case
|
||||||
// JSON escaping. Reserve before schema cloning; never serialize to measure.
|
// JSON escaping. Check before schema cloning; never serialize to measure.
|
||||||
const MAX_EVENT_COST = 4 * 1024 * 1024;
|
|
||||||
const MAX_CAPTURE_COST = 16 * 1024 * 1024;
|
|
||||||
const MAX_EVENTS = 10_000;
|
|
||||||
const MAX_LISTENER_OWNERS = 2048;
|
const MAX_LISTENER_OWNERS = 2048;
|
||||||
const allocationCost = (value: unknown, remaining: number): number => {
|
const allocationCost = (value: unknown, remaining: number): number => {
|
||||||
if (typeof value === "string") return value.length * 8 + 2;
|
if (typeof value === "string") return value.length * 8 + 2;
|
||||||
@@ -65,15 +71,28 @@ const allocationCost = (value: unknown, remaining: number): number => {
|
|||||||
return cost;
|
return cost;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EventKind = BrowserScenarioEvent["kind"];
|
||||||
/** Arrival-ordered Playwright event capture with current-step attribution. */
|
/** Arrival-ordered Playwright event capture with current-step attribution. */
|
||||||
export class PlaywrightScenarioEvents {
|
export class PlaywrightScenarioEvents {
|
||||||
private readonly items: BrowserScenarioEvent[] = [];
|
private readonly items: BrowserScenarioEvent[] = [];
|
||||||
private retainedCost = 0;
|
private retainedCost = 0;
|
||||||
private dropped = 0;
|
private dropped = 0;
|
||||||
|
private readonly omittedKinds = new Map<
|
||||||
|
EventKind,
|
||||||
|
{ count: number; reasons: Set<BrowserScenarioEventBudgetReason> }
|
||||||
|
>();
|
||||||
|
private readonly omittedNetwork = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
readonly kinds: Set<EventKind>;
|
||||||
|
readonly reasons: Set<BrowserScenarioEventBudgetReason>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
private stepIndex = 0;
|
private stepIndex = 0;
|
||||||
private sequence = 0;
|
private sequence = 0;
|
||||||
private readonly enabled: ReadonlySet<EventName>;
|
private readonly enabled: ReadonlySet<EventName>;
|
||||||
private readonly secrets: BrowserScenarioSecrets;
|
private readonly secrets: BrowserScenarioSecrets;
|
||||||
|
private readonly limits: BrowserScenarioCaptureLimits;
|
||||||
private readonly ownsContext: boolean;
|
private readonly ownsContext: boolean;
|
||||||
private pages = new WeakSet<Page>();
|
private pages = new WeakSet<Page>();
|
||||||
private readonly incompleteFamilies = new Set<EventName>();
|
private readonly incompleteFamilies = new Set<EventName>();
|
||||||
@@ -92,6 +111,7 @@ export class PlaywrightScenarioEvents {
|
|||||||
if (network.request_body || network.response_body || network.header_values)
|
if (network.request_body || network.response_body || network.header_values)
|
||||||
this.enabled = new Set([...this.enabled, "network"]);
|
this.enabled = new Set([...this.enabled, "network"]);
|
||||||
this.secrets = options.secrets;
|
this.secrets = options.secrets;
|
||||||
|
this.limits = options.limits ?? DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS;
|
||||||
this.ownsContext = options.ownsContext === true;
|
this.ownsContext = options.ownsContext === true;
|
||||||
this.pages.add(options.page);
|
this.pages.add(options.page);
|
||||||
if (this.ownsContext && this.enabled.size > 0) {
|
if (this.ownsContext && this.enabled.size > 0) {
|
||||||
@@ -104,9 +124,13 @@ export class PlaywrightScenarioEvents {
|
|||||||
const context = options.context ?? options.page.context();
|
const context = options.context ?? options.page.context();
|
||||||
const collector = new PlaywrightScenarioNetwork({
|
const collector = new PlaywrightScenarioNetwork({
|
||||||
selection: network,
|
selection: network,
|
||||||
|
limits: this.limits,
|
||||||
secrets: options.secrets,
|
secrets: options.secrets,
|
||||||
emit: (event) => this.push(event),
|
emit: (event) => this.push(event),
|
||||||
admitText: (values) => this.admitProducerText(values),
|
admitText: (kind, values) => this.admitProducerText(kind, values, true),
|
||||||
|
admitContentBody: (input) => this.admitContentBody(input),
|
||||||
|
omitNetwork: (id, kind, reason) => this.omitNetwork(id, kind, reason),
|
||||||
|
omitEvent: (kind, reason) => this.omitEvent(kind, reason),
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
});
|
});
|
||||||
this.network = collector;
|
this.network = collector;
|
||||||
@@ -181,7 +205,7 @@ export class PlaywrightScenarioEvents {
|
|||||||
const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])];
|
const gaps = [...popupGaps, ...(this.network?.limitations() ?? [])];
|
||||||
if (this.dropped > 0)
|
if (this.dropped > 0)
|
||||||
gaps.push(
|
gaps.push(
|
||||||
"Browser event collection reached its resource budget; omitted events and descendant observations are unknown.",
|
"Browser event collection reached a selected resource budget; per-kind omissions and affected network transaction IDs are listed in events.omissions, and event coverage is incomplete.",
|
||||||
);
|
);
|
||||||
return this.enabled.has("network") && !this.ownsContext
|
return this.enabled.has("network") && !this.ownsContext
|
||||||
? [
|
? [
|
||||||
@@ -195,25 +219,55 @@ export class PlaywrightScenarioEvents {
|
|||||||
readonly retained: number;
|
readonly retained: number;
|
||||||
readonly dropped: number;
|
readonly dropped: number;
|
||||||
readonly items: readonly BrowserScenarioEvent[];
|
readonly items: readonly BrowserScenarioEvent[];
|
||||||
|
readonly omissions: BrowserScenarioEventOmissions;
|
||||||
} {
|
} {
|
||||||
|
const omittedKinds = new Map(
|
||||||
|
[...this.omittedKinds].map(
|
||||||
|
([kind, omission]) =>
|
||||||
|
[
|
||||||
|
kind,
|
||||||
|
{ count: omission.count, reasons: new Set(omission.reasons) },
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
retained: this.items.length,
|
retained: this.items.length,
|
||||||
dropped: this.dropped,
|
dropped: this.dropped,
|
||||||
items: this.items,
|
items: this.items,
|
||||||
|
omissions: {
|
||||||
|
by_kind: [...omittedKinds]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([kind, omission]) => ({
|
||||||
|
kind,
|
||||||
|
count: omission.count,
|
||||||
|
reasons: [...omission.reasons].sort(),
|
||||||
|
})),
|
||||||
|
network_transactions: [...this.omittedNetwork]
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
.map(([transaction_id, omission]) => ({
|
||||||
|
transaction_id,
|
||||||
|
omitted_kinds: [...omission.kinds].sort(),
|
||||||
|
reasons: [...omission.reasons].sort(),
|
||||||
|
})),
|
||||||
|
network_transaction_range:
|
||||||
|
this.network?.omittedTransactionRange() ?? null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private push(event: UnindexedEvent): number {
|
private push(event: UnindexedEvent): BrowserScenarioEventAdmission {
|
||||||
if (this.sealed) return 0;
|
|
||||||
this.sequence += 1;
|
this.sequence += 1;
|
||||||
if (this.items.length >= MAX_EVENTS) {
|
const cost =
|
||||||
this.dropped += 1;
|
allocationCost(event, this.limits.max_event_allocation_bytes) + 128;
|
||||||
return 0;
|
const reason = this.admissionReason(cost);
|
||||||
}
|
if (this.sealed || reason !== undefined) {
|
||||||
const cost = allocationCost(event, MAX_EVENT_COST) + 128;
|
const finalReason = reason ?? "max-events";
|
||||||
if (cost > MAX_EVENT_COST || this.retainedCost + cost > MAX_CAPTURE_COST) {
|
this.omitEvent(event.kind, finalReason);
|
||||||
this.dropped += 1;
|
return {
|
||||||
return 0;
|
status: "omitted",
|
||||||
|
sequence: this.sequence,
|
||||||
|
reason: finalReason,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
this.retainedCost += cost;
|
this.retainedCost += cost;
|
||||||
const parsed = browserScenarioEventSchema.parse({
|
const parsed = browserScenarioEventSchema.parse({
|
||||||
@@ -222,44 +276,134 @@ export class PlaywrightScenarioEvents {
|
|||||||
step_index: this.stepIndex,
|
step_index: this.stepIndex,
|
||||||
});
|
});
|
||||||
this.items.push(parsed);
|
this.items.push(parsed);
|
||||||
return this.sequence;
|
return { status: "retained", sequence: this.sequence };
|
||||||
}
|
}
|
||||||
|
|
||||||
private admitText(...values: readonly string[]): boolean {
|
private admitText(kind: EventKind, ...values: readonly string[]): boolean {
|
||||||
return this.admitProducerText(values);
|
return this.admitProducerText(kind, values, false).status === "retained";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admit conservative output cost before running redaction or cloning. */
|
/** Admit conservative output cost before running redaction or cloning. */
|
||||||
private admitProducerText(values: Iterable<string>): boolean {
|
private admitProducerText(
|
||||||
if (this.sealed) return false;
|
kind: EventKind,
|
||||||
|
values: Iterable<string>,
|
||||||
|
networkMetadata: boolean,
|
||||||
|
): BrowserScenarioEventAdmission {
|
||||||
let cost = 4096;
|
let cost = 4096;
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
cost += this.secrets.redactionAllocationCost(value.length);
|
cost += this.secrets.redactionAllocationCost(value.length);
|
||||||
if (
|
|
||||||
cost > MAX_EVENT_COST ||
|
|
||||||
cost > MAX_CAPTURE_COST - this.retainedCost
|
|
||||||
) {
|
|
||||||
this.sequence += 1;
|
|
||||||
this.dropped += 1;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (
|
const reason =
|
||||||
this.items.length >= MAX_EVENTS ||
|
networkMetadata &&
|
||||||
cost > MAX_EVENT_COST ||
|
cost > this.limits.max_network_metadata_allocation_bytes
|
||||||
cost > MAX_CAPTURE_COST - this.retainedCost
|
? "max-network-metadata-allocation-bytes"
|
||||||
) {
|
: this.admissionReason(cost);
|
||||||
|
if (reason !== undefined) {
|
||||||
this.sequence += 1;
|
this.sequence += 1;
|
||||||
this.dropped += 1;
|
this.omitEvent(kind, reason);
|
||||||
return false;
|
return { status: "omitted", sequence: this.sequence, reason };
|
||||||
}
|
}
|
||||||
return true;
|
return { status: "retained", sequence: this.sequence + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
private admitOwner(): boolean {
|
private admissionReason(
|
||||||
if (this.sealed) return false;
|
cost: number,
|
||||||
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
|
): BrowserScenarioEventBudgetReason | undefined {
|
||||||
|
if (this.sealed || this.items.length >= this.limits.max_events)
|
||||||
|
return "max-events";
|
||||||
|
if (cost > this.limits.max_event_allocation_bytes)
|
||||||
|
return "max-event-allocation-bytes";
|
||||||
|
if (cost > this.limits.max_total_event_allocation_bytes - this.retainedCost)
|
||||||
|
return "max-total-event-allocation-bytes";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private admitContentBody(input: {
|
||||||
|
readonly transactionId: string;
|
||||||
|
readonly sourceEventSequence: number;
|
||||||
|
readonly phase: "request" | "response";
|
||||||
|
readonly headers: BrowserNetworkHeaders;
|
||||||
|
readonly observedBytes: number;
|
||||||
|
readonly allocationBytes: number;
|
||||||
|
readonly mediaType: string | undefined;
|
||||||
|
}): BrowserScenarioEventAdmission {
|
||||||
|
const kind = "network-content";
|
||||||
|
const eventCost =
|
||||||
|
allocationCost(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
sequence: this.sequence + 1,
|
||||||
|
step_index: this.stepIndex,
|
||||||
|
transaction_id: input.transactionId,
|
||||||
|
source_event_sequence: input.sourceEventSequence,
|
||||||
|
phase: input.phase,
|
||||||
|
headers: input.headers,
|
||||||
|
body: {
|
||||||
|
state: "captured",
|
||||||
|
representation:
|
||||||
|
input.phase === "request"
|
||||||
|
? "browser-exposed-request-bytes"
|
||||||
|
: "playwright-response-body-bytes",
|
||||||
|
encoding: "base64",
|
||||||
|
content: null,
|
||||||
|
bytes: input.observedBytes,
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
media_type: null,
|
||||||
|
redacted: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.limits.max_event_allocation_bytes,
|
||||||
|
) -
|
||||||
|
64 +
|
||||||
|
input.allocationBytes +
|
||||||
|
(input.mediaType === undefined
|
||||||
|
? 0
|
||||||
|
: Math.max(
|
||||||
|
input.mediaType.length * 8 + 2,
|
||||||
|
this.secrets.redactionAllocationCost(input.mediaType.length),
|
||||||
|
)) +
|
||||||
|
128;
|
||||||
|
const reason = this.admissionReason(eventCost);
|
||||||
|
if (reason !== undefined) {
|
||||||
|
this.sequence += 1;
|
||||||
|
this.omitEvent(kind, reason);
|
||||||
|
return { status: "omitted", sequence: this.sequence, reason };
|
||||||
|
}
|
||||||
|
return { status: "retained", sequence: this.sequence + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private omitEvent(
|
||||||
|
kind: EventKind,
|
||||||
|
reason: BrowserScenarioEventBudgetReason,
|
||||||
|
): void {
|
||||||
this.dropped += 1;
|
this.dropped += 1;
|
||||||
|
let omission = this.omittedKinds.get(kind);
|
||||||
|
if (omission === undefined) {
|
||||||
|
omission = { count: 0, reasons: new Set() };
|
||||||
|
this.omittedKinds.set(kind, omission);
|
||||||
|
}
|
||||||
|
omission.count += 1;
|
||||||
|
omission.reasons.add(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private omitNetwork(
|
||||||
|
transactionId: string,
|
||||||
|
kind: EventKind,
|
||||||
|
reason: BrowserScenarioEventBudgetReason,
|
||||||
|
): void {
|
||||||
|
let omission = this.omittedNetwork.get(transactionId);
|
||||||
|
if (omission === undefined) {
|
||||||
|
omission = { kinds: new Set(), reasons: new Set() };
|
||||||
|
this.omittedNetwork.set(transactionId, omission);
|
||||||
|
}
|
||||||
|
omission.kinds.add(kind);
|
||||||
|
omission.reasons.add(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private admitOwner(kind: EventKind): boolean {
|
||||||
|
if (this.stopListeners.size < MAX_LISTENER_OWNERS) return true;
|
||||||
|
this.sequence += 1;
|
||||||
|
this.omitEvent(kind, "max-listener-owners");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +438,8 @@ export class PlaywrightScenarioEvents {
|
|||||||
}
|
}
|
||||||
if (this.enabled.has("page-errors")) {
|
if (this.enabled.has("page-errors")) {
|
||||||
const onError = (error: Error) => {
|
const onError = (error: Error) => {
|
||||||
if (!this.admitText(error.message, error.stack ?? "")) return;
|
if (!this.admitText("page-error", error.message, error.stack ?? ""))
|
||||||
|
return;
|
||||||
this.push({
|
this.push({
|
||||||
kind: "page-error",
|
kind: "page-error",
|
||||||
message: this.secrets.redact(error.message),
|
message: this.secrets.redact(error.message),
|
||||||
@@ -348,7 +493,8 @@ export class PlaywrightScenarioEvents {
|
|||||||
private console(message: ConsoleMessage): void {
|
private console(message: ConsoleMessage): void {
|
||||||
const rawText = message.text();
|
const rawText = message.text();
|
||||||
const location = message.location();
|
const location = message.location();
|
||||||
if (!this.admitText(rawText, location.url, message.type())) return;
|
if (!this.admitText("console", rawText, location.url, message.type()))
|
||||||
|
return;
|
||||||
this.push({
|
this.push({
|
||||||
kind: "console",
|
kind: "console",
|
||||||
level: this.secrets.redact(message.type()),
|
level: this.secrets.redact(message.type()),
|
||||||
@@ -358,8 +504,8 @@ export class PlaywrightScenarioEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private webSocket(socket: WebSocket): void {
|
private webSocket(socket: WebSocket): void {
|
||||||
if (!this.admitOwner()) return;
|
if (!this.admitOwner("websocket-opened")) return;
|
||||||
if (!this.admitText(socket.url())) return;
|
if (!this.admitText("websocket-opened", socket.url())) return;
|
||||||
const url = this.safeUrl(socket.url());
|
const url = this.safeUrl(socket.url());
|
||||||
this.push({ kind: "websocket-opened", url });
|
this.push({ kind: "websocket-opened", url });
|
||||||
const onSent = ({ payload }: { payload: string | Buffer }) =>
|
const onSent = ({ payload }: { payload: string | Buffer }) =>
|
||||||
@@ -396,7 +542,7 @@ export class PlaywrightScenarioEvents {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.admitText(payload)) return;
|
if (!this.admitText(kind, payload)) return;
|
||||||
const text = this.secrets.redact(payload);
|
const text = this.secrets.redact(payload);
|
||||||
this.push({
|
this.push({
|
||||||
kind,
|
kind,
|
||||||
@@ -412,7 +558,7 @@ export class PlaywrightScenarioEvents {
|
|||||||
kind: "frame-attached" | "frame-detached" | "frame-navigated",
|
kind: "frame-attached" | "frame-detached" | "frame-navigated",
|
||||||
frame: Frame,
|
frame: Frame,
|
||||||
): void {
|
): void {
|
||||||
if (!this.admitText(frame.url(), frame.name())) return;
|
if (!this.admitText(kind, frame.url(), frame.name())) return;
|
||||||
this.push({
|
this.push({
|
||||||
kind,
|
kind,
|
||||||
url: frame.url() === "" ? null : this.safeUrl(frame.url()),
|
url: frame.url() === "" ? null : this.safeUrl(frame.url()),
|
||||||
@@ -421,7 +567,11 @@ export class PlaywrightScenarioEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private worker(worker: Worker): void {
|
private worker(worker: Worker): void {
|
||||||
if (!this.admitOwner() || !this.admitText(worker.url())) return;
|
if (
|
||||||
|
!this.admitOwner("worker-created") ||
|
||||||
|
!this.admitText("worker-created", worker.url())
|
||||||
|
)
|
||||||
|
return;
|
||||||
const details = {
|
const details = {
|
||||||
url: this.safeUrl(worker.url()),
|
url: this.safeUrl(worker.url()),
|
||||||
name: null,
|
name: null,
|
||||||
@@ -437,7 +587,11 @@ export class PlaywrightScenarioEvents {
|
|||||||
|
|
||||||
private popup(page: Page): void {
|
private popup(page: Page): void {
|
||||||
if (this.pages.has(page)) return;
|
if (this.pages.has(page)) return;
|
||||||
if (!this.admitOwner() || !this.admitText(page.url())) return;
|
if (
|
||||||
|
!this.admitOwner("popup-opened") ||
|
||||||
|
!this.admitText("popup-opened", page.url())
|
||||||
|
)
|
||||||
|
return;
|
||||||
this.pages.add(page);
|
this.pages.add(page);
|
||||||
for (const family of [
|
for (const family of [
|
||||||
"frames",
|
"frames",
|
||||||
@@ -453,7 +607,7 @@ export class PlaywrightScenarioEvents {
|
|||||||
if (this.enabled.has("popups")) {
|
if (this.enabled.has("popups")) {
|
||||||
this.push({ kind: "popup-opened", ...opened });
|
this.push({ kind: "popup-opened", ...opened });
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
if (!this.admitText(page.url())) return;
|
if (!this.admitText("popup-closed", page.url())) return;
|
||||||
this.push({
|
this.push({
|
||||||
kind: "popup-closed",
|
kind: "popup-closed",
|
||||||
url: page.url() === "" ? null : this.safeUrl(page.url()),
|
url: page.url() === "" ? null : this.safeUrl(page.url()),
|
||||||
@@ -467,7 +621,13 @@ export class PlaywrightScenarioEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private download(download: Download): void {
|
private download(download: Download): void {
|
||||||
if (this.admitText(download.suggestedFilename(), download.url()))
|
if (
|
||||||
|
this.admitText(
|
||||||
|
"download-cancelled",
|
||||||
|
download.suggestedFilename(),
|
||||||
|
download.url(),
|
||||||
|
)
|
||||||
|
)
|
||||||
this.push({
|
this.push({
|
||||||
kind: "download-cancelled",
|
kind: "download-cancelled",
|
||||||
suggested_filename: this.secrets.redact(download.suggestedFilename()),
|
suggested_filename: this.secrets.redact(download.suggestedFilename()),
|
||||||
|
|||||||
@@ -207,23 +207,29 @@ export class PlaywrightScenarioServiceWorkers {
|
|||||||
async assertBlocked(): Promise<void> {
|
async assertBlocked(): Promise<void> {
|
||||||
if (this.#session !== undefined) await this.settle();
|
if (this.#session !== undefined) await this.settle();
|
||||||
for (const frame of this.page.frames()) {
|
for (const frame of this.page.frames()) {
|
||||||
let violatesPolicy: unknown;
|
let failure: unknown;
|
||||||
try {
|
try {
|
||||||
violatesPolicy = await frame.evaluate(`(async () => {
|
failure = await frame.evaluate(`(async () => {
|
||||||
if (!("serviceWorker" in navigator)) return false;
|
if (!("serviceWorker" in navigator)) return null;
|
||||||
const serviceWorker = navigator.serviceWorker;
|
const serviceWorker = navigator.serviceWorker;
|
||||||
if (${this.#session !== undefined} &&
|
if (${this.#session !== undefined} &&
|
||||||
(!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) ||
|
(!Object.hasOwn(serviceWorker.register, Symbol.for(${this.marker})) ||
|
||||||
typeof globalThis[${this.restoreKey}] !== "function")) return true;
|
typeof globalThis[${this.restoreKey}] !== "function")) return "policy_unprotected";
|
||||||
if (serviceWorker.controller !== null) return true;
|
if (serviceWorker.controller !== null) return "service_worker_blocked";
|
||||||
return (await serviceWorker.getRegistrations()).length > 0;
|
return (await serviceWorker.getRegistrations()).length > 0
|
||||||
|
? "service_worker_blocked"
|
||||||
|
: null;
|
||||||
})()`);
|
})()`);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (frame.isDetached()) continue;
|
if (frame.isDetached()) continue;
|
||||||
throw cause;
|
throw cause;
|
||||||
}
|
}
|
||||||
if (violatesPolicy)
|
if (failure === "policy_unprotected")
|
||||||
throw new BrowserObservationError(OPERATION, "target_not_allowed");
|
throw new BrowserObservationError(OPERATION, "target_not_allowed");
|
||||||
|
if (failure === "service_worker_blocked")
|
||||||
|
throw new BrowserObservationError(OPERATION, "service_worker_blocked");
|
||||||
|
if (failure !== null)
|
||||||
|
throw new BrowserObservationError(OPERATION, "protocol_error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,16 @@ vi.mock("./PlaywrightScenarioEvents.js", () => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
result() {
|
result() {
|
||||||
return { events: [] };
|
return {
|
||||||
|
retained: 0,
|
||||||
|
dropped: 0,
|
||||||
|
items: [],
|
||||||
|
omissions: {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
limitations() {
|
limitations() {
|
||||||
@@ -105,7 +114,7 @@ const fixture = () => {
|
|||||||
const page = Object.assign(new EventEmitter(), {
|
const page = Object.assign(new EventEmitter(), {
|
||||||
url: () => "about:blank",
|
url: () => "about:blank",
|
||||||
goto: vi.fn(async () => undefined),
|
goto: vi.fn(async () => undefined),
|
||||||
evaluate: vi.fn(async (_script: unknown) => false),
|
evaluate: vi.fn(async (_script: unknown): Promise<unknown> => null),
|
||||||
addInitScript: vi.fn(async (_script: unknown) => undefined),
|
addInitScript: vi.fn(async (_script: unknown) => undefined),
|
||||||
context: () => context,
|
context: () => context,
|
||||||
frames: () => [page],
|
frames: () => [page],
|
||||||
@@ -190,7 +199,9 @@ it("stops attached navigation without storage and retries rejected policy cleanu
|
|||||||
|
|
||||||
it("rejects an init script accepted by CDP when the live realm is unprotected", async () => {
|
it("rejects an init script accepted by CDP when the live realm is unprotected", async () => {
|
||||||
const { page, cleanup } = fixture();
|
const { page, cleanup } = fixture();
|
||||||
page.evaluate.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
page.evaluate
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce("policy_unprotected");
|
||||||
const attached = browserScenarioSchema.parse({
|
const attached = browserScenarioSchema.parse({
|
||||||
...scenario,
|
...scenario,
|
||||||
browser: {
|
browser: {
|
||||||
@@ -484,7 +495,7 @@ it("waits for a pending registration installation before cancelled cleanup", asy
|
|||||||
|
|
||||||
it("rejects attached block when caller-owned registrations already exist", async () => {
|
it("rejects attached block when caller-owned registrations already exist", async () => {
|
||||||
const { cleanup, page } = fixture();
|
const { cleanup, page } = fixture();
|
||||||
page.evaluate.mockResolvedValueOnce(true);
|
page.evaluate.mockResolvedValueOnce("service_worker_blocked");
|
||||||
const attached = browserScenarioSchema.parse({
|
const attached = browserScenarioSchema.parse({
|
||||||
...scenario,
|
...scenario,
|
||||||
browser: {
|
browser: {
|
||||||
@@ -499,7 +510,7 @@ it("rejects attached block when caller-owned registrations already exist", async
|
|||||||
PlaywrightScenarioSession.open(attached, {}, {}),
|
PlaywrightScenarioSession.open(attached, {}, {}),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
_tag: "BrowserObservationError",
|
_tag: "BrowserObservationError",
|
||||||
reason: "target_not_allowed",
|
reason: "service_worker_blocked",
|
||||||
});
|
});
|
||||||
expect(page.addInitScript).not.toHaveBeenCalled();
|
expect(page.addInitScript).not.toHaveBeenCalled();
|
||||||
expect(cleanup.close).toHaveBeenCalledTimes(1);
|
expect(cleanup.close).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
|
|||||||
enabled: new Set(scenario.capture.events),
|
enabled: new Set(scenario.capture.events),
|
||||||
secrets,
|
secrets,
|
||||||
network: scenario.capture.network,
|
network: scenario.capture.network,
|
||||||
|
limits: scenario.capture.limits,
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
});
|
});
|
||||||
return new PlaywrightScenarioSession(opened, {
|
return new PlaywrightScenarioSession(opened, {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { fetchWebSourceMaps } from "./WebSourceMapFetcher.js";
|
||||||
|
import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js";
|
||||||
|
|
||||||
|
const input = analyzeWebBundleInputSchema.parse({
|
||||||
|
cdp_endpoint: "http://127.0.0.1:9222",
|
||||||
|
allowed_origins: ["http://127.0.0.1:9222"],
|
||||||
|
target_id: "page-1",
|
||||||
|
fetch_source_maps: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an empty selected request set without starting fetch work", async () => {
|
||||||
|
const result = await fetchWebSourceMaps([], input, undefined, {
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error("No source-map request should be made");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "not_requested",
|
||||||
|
requested: 0,
|
||||||
|
processed: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors cancellation before returning an empty request set", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const reason = new Error("cancelled before fetching");
|
||||||
|
controller.abort(reason);
|
||||||
|
|
||||||
|
await expect(fetchWebSourceMaps([], input, controller.signal)).rejects.toBe(
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -81,6 +81,14 @@ export const fetchWebSourceMaps = async (
|
|||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
host: SourceMapFetchHost = { fetch: globalThis.fetch },
|
host: SourceMapFetchHost = { fetch: globalThis.fetch },
|
||||||
): Promise<SourceMaps> => {
|
): Promise<SourceMaps> => {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (requests.length === 0)
|
||||||
|
return webSourceMapsSchema.parse({
|
||||||
|
status: "not_requested",
|
||||||
|
requested: 0,
|
||||||
|
processed: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
const items: SourceMapItem[] = [];
|
const items: SourceMapItem[] = [];
|
||||||
const operationController = new AbortController();
|
const operationController = new AbortController();
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
@@ -170,6 +178,10 @@ export const fetchWebSourceMaps = async (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const throwIfAborted = (signal?: AbortSignal): void => {
|
||||||
|
if (signal?.aborted === true) throw signal.reason;
|
||||||
|
};
|
||||||
|
|
||||||
const sourceMapOutputLimitation = (unprocessed: number): string =>
|
const sourceMapOutputLimitation = (unprocessed: number): string =>
|
||||||
`source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`;
|
`source-map output budget could not retain results for ${String(unprocessed)} requested map${unprocessed === 1 ? "" : "s"}. their results are unknown.`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import type {
|
|||||||
BrowserNetworkBody,
|
BrowserNetworkBody,
|
||||||
BrowserNetworkHeaders,
|
BrowserNetworkHeaders,
|
||||||
} from "../../domain/browserNetworkEvidence.js";
|
} from "../../domain/browserNetworkEvidence.js";
|
||||||
|
import type {
|
||||||
|
BrowserScenarioEventAdmission,
|
||||||
|
BrowserScenarioEventBudgetReason,
|
||||||
|
} from "../../domain/browserScenarioCaptureValues.js";
|
||||||
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
|
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
|
||||||
import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js";
|
import { withPlaywrightExecutionBoundary } from "../PlaywrightExecutionBoundary.js";
|
||||||
|
|
||||||
@@ -20,9 +24,26 @@ export interface NetworkHeadersSource {
|
|||||||
/** A bounded read cannot delay browser cleanup indefinitely. */
|
/** A bounded read cannot delay browser cleanup indefinitely. */
|
||||||
export interface NetworkReadOptions {
|
export interface NetworkReadOptions {
|
||||||
readonly timeoutMs: number;
|
readonly timeoutMs: number;
|
||||||
|
readonly maxBodyBytes: number;
|
||||||
|
readonly maxBodyAllocationBytes: number;
|
||||||
|
readonly maxHeaderAllocationBytes: number;
|
||||||
|
readonly maxMetadataAllocationBytes: number;
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NetworkBodyCapture =
|
||||||
|
| BrowserNetworkBody
|
||||||
|
| {
|
||||||
|
readonly state: "omitted";
|
||||||
|
readonly reason: BrowserScenarioEventBudgetReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NetworkBodyPreflight = (input: {
|
||||||
|
readonly observedBytes: number;
|
||||||
|
readonly allocationBytes: number;
|
||||||
|
readonly mediaType: string | undefined;
|
||||||
|
}) => BrowserScenarioEventAdmission;
|
||||||
|
|
||||||
const credentialHeaders = new Set([
|
const credentialHeaders = new Set([
|
||||||
"authorization",
|
"authorization",
|
||||||
"proxy-authorization",
|
"proxy-authorization",
|
||||||
@@ -33,12 +54,14 @@ const credentialHeaders = new Set([
|
|||||||
const errorMessage = (
|
const errorMessage = (
|
||||||
cause: unknown,
|
cause: unknown,
|
||||||
secrets: BrowserScenarioSecrets,
|
secrets: BrowserScenarioSecrets,
|
||||||
|
maxMetadataAllocationBytes: number,
|
||||||
): string => {
|
): string => {
|
||||||
const message =
|
const message =
|
||||||
cause instanceof Error && cause.message !== ""
|
cause instanceof Error && cause.message !== ""
|
||||||
? cause.message
|
? cause.message
|
||||||
: "Network read failed";
|
: "Network read failed";
|
||||||
return secrets.redactionAllocationCost(message.length) <= 1024 * 1024
|
return secrets.redactionAllocationCost(message.length) <=
|
||||||
|
maxMetadataAllocationBytes
|
||||||
? secrets.redact(message)
|
? secrets.redact(message)
|
||||||
: "Network read failed; diagnostic exceeds the allocation budget.";
|
: "Network read failed; diagnostic exceeds the allocation budget.";
|
||||||
};
|
};
|
||||||
@@ -58,10 +81,10 @@ export const captureNetworkHeaders = async (
|
|||||||
let cost = 0;
|
let cost = 0;
|
||||||
for (const { name, value } of headers) {
|
for (const { name, value } of headers) {
|
||||||
cost += secrets.redactionAllocationCost(name.length + value.length) + 128;
|
cost += secrets.redactionAllocationCost(name.length + value.length) + 128;
|
||||||
if (cost > 1024 * 1024)
|
if (cost > options.maxHeaderAllocationBytes)
|
||||||
return {
|
return {
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
message: "Headers exceed the 1 MiB allocation budget.",
|
message: `Headers exceed the ${String(options.maxHeaderAllocationBytes)}-byte allocation budget.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -80,7 +103,10 @@ export const captureNetworkHeaders = async (
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return { state: "unavailable", message: errorMessage(cause, secrets) };
|
return {
|
||||||
|
state: "unavailable",
|
||||||
|
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +117,8 @@ export const captureNetworkBody = async (
|
|||||||
source: NetworkHeadersSource,
|
source: NetworkHeadersSource,
|
||||||
secrets: BrowserScenarioSecrets,
|
secrets: BrowserScenarioSecrets,
|
||||||
options: NetworkReadOptions,
|
options: NetworkReadOptions,
|
||||||
): Promise<BrowserNetworkBody> => {
|
preflight?: NetworkBodyPreflight,
|
||||||
|
): Promise<NetworkBodyCapture> => {
|
||||||
try {
|
try {
|
||||||
const observed = await withPlaywrightExecutionBoundary(
|
const observed = await withPlaywrightExecutionBoundary(
|
||||||
read,
|
read,
|
||||||
@@ -101,29 +128,55 @@ export const captureNetworkBody = async (
|
|||||||
if (observed === null) return { state: "not_exposed" };
|
if (observed === null) return { state: "not_exposed" };
|
||||||
// This bounds subsequent redaction, base64 and validation copies, but the
|
// This bounds subsequent redaction, base64 and validation copies, but the
|
||||||
// Playwright API itself has already materialized the body (see PR limits).
|
// Playwright API itself has already materialized the body (see PR limits).
|
||||||
if (observed.length > 64 * 1024)
|
const maxBodyBytes = options.maxBodyBytes;
|
||||||
|
if (observed.length > maxBodyBytes)
|
||||||
return {
|
return {
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
reason: "body-read-failed",
|
reason: "body-size-limit",
|
||||||
message: "Body exceeds the 64 KiB retained-content budget.",
|
message: `Playwright materialized the body before its ${String(maxBodyBytes)}-byte retained-body budget was checked.`,
|
||||||
|
limit_bytes: maxBodyBytes,
|
||||||
|
observed_bytes: observed.length,
|
||||||
};
|
};
|
||||||
if (secrets.redactionAllocationCost(observed.length) > 1024 * 1024)
|
const bodyAllocationCost = secrets.redactionAllocationCost(observed.length);
|
||||||
|
const base64AllocationCost = Math.ceil(observed.length / 3) * 4 * 8 + 2;
|
||||||
|
const maxBodyAllocationBytes = options.maxBodyAllocationBytes;
|
||||||
|
const observedBodyAllocationBytes = Math.max(
|
||||||
|
bodyAllocationCost,
|
||||||
|
base64AllocationCost,
|
||||||
|
);
|
||||||
|
if (observedBodyAllocationBytes > maxBodyAllocationBytes)
|
||||||
return {
|
return {
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
reason: "body-read-failed",
|
reason: "body-allocation-limit",
|
||||||
message: "Body redaction exceeds the allocation budget.",
|
message:
|
||||||
|
"Body redaction exceeds the selected network body allocation budget.",
|
||||||
|
limit_allocation_bytes: maxBodyAllocationBytes,
|
||||||
|
observed_allocation_bytes: observedBodyAllocationBytes,
|
||||||
};
|
};
|
||||||
const retained = secrets.redactBytes(observed);
|
|
||||||
const contentType = source.headers()["content-type"];
|
const contentType = source.headers()["content-type"];
|
||||||
if (
|
if (
|
||||||
contentType !== undefined &&
|
contentType !== undefined &&
|
||||||
secrets.redactionAllocationCost(contentType.length) > 64 * 1024
|
secrets.redactionAllocationCost(contentType.length) >
|
||||||
|
options.maxHeaderAllocationBytes
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
reason: "body-read-failed",
|
reason: "body-allocation-limit",
|
||||||
message: "Content type exceeds the retained metadata budget.",
|
message:
|
||||||
|
"Content type exceeds the selected network header allocation budget.",
|
||||||
|
limit_allocation_bytes: options.maxHeaderAllocationBytes,
|
||||||
|
observed_allocation_bytes: secrets.redactionAllocationCost(
|
||||||
|
contentType.length,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
const admission = preflight?.({
|
||||||
|
observedBytes: observed.length,
|
||||||
|
allocationBytes: observedBodyAllocationBytes,
|
||||||
|
mediaType: contentType,
|
||||||
|
});
|
||||||
|
if (admission?.status === "omitted")
|
||||||
|
return { state: "omitted", reason: admission.reason };
|
||||||
|
const retained = secrets.redactBytes(observed);
|
||||||
return {
|
return {
|
||||||
state: "captured",
|
state: "captured",
|
||||||
representation:
|
representation:
|
||||||
@@ -149,7 +202,7 @@ export const captureNetworkBody = async (
|
|||||||
: cause instanceof AnalysisTimeoutError
|
: cause instanceof AnalysisTimeoutError
|
||||||
? "body-read-timeout"
|
? "body-read-timeout"
|
||||||
: "body-read-failed",
|
: "body-read-failed",
|
||||||
message: errorMessage(cause, secrets),
|
message: errorMessage(cause, secrets, options.maxMetadataAllocationBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import { afterEach, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { browserScenarioSchema } from "../../domain/browserScenario.js";
|
import { browserScenarioSchema } from "../../domain/browserScenario.js";
|
||||||
import {
|
import {
|
||||||
|
type BrowserScenarioEventAdmission,
|
||||||
browserScenarioEventSchema,
|
browserScenarioEventSchema,
|
||||||
type BrowserScenarioEvent,
|
type BrowserScenarioEvent,
|
||||||
} from "../../domain/browserScenarioCaptureValues.js";
|
} from "../../domain/browserScenarioCaptureValues.js";
|
||||||
@@ -100,6 +101,7 @@ const harness = (
|
|||||||
selected: BrowserNetworkContentSelection = selection,
|
selected: BrowserNetworkContentSelection = selection,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
secretValue = "私密",
|
secretValue = "私密",
|
||||||
|
limitsInput: unknown = {},
|
||||||
) => {
|
) => {
|
||||||
const scenario = browserScenarioSchema.parse({
|
const scenario = browserScenarioSchema.parse({
|
||||||
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
browser: { mode: "launch", executable_path: "/fixture/chrome" },
|
||||||
@@ -108,14 +110,17 @@ const harness = (
|
|||||||
secrets: [
|
secrets: [
|
||||||
{ secret_id: "declared", environment_variable: "FIXTURE_SECRET" },
|
{ secret_id: "declared", environment_variable: "FIXTURE_SECRET" },
|
||||||
],
|
],
|
||||||
|
capture: { limits: limitsInput },
|
||||||
});
|
});
|
||||||
const secrets = BrowserScenarioSecrets.resolve(scenario, {
|
const secrets = BrowserScenarioSecrets.resolve(scenario, {
|
||||||
FIXTURE_SECRET: secretValue,
|
FIXTURE_SECRET: secretValue,
|
||||||
});
|
});
|
||||||
if (secrets === undefined) throw new Error("Fixture secret was not resolved");
|
if (secrets === undefined) throw new Error("Fixture secret was not resolved");
|
||||||
const events: BrowserScenarioEvent[] = [];
|
const events: BrowserScenarioEvent[] = [];
|
||||||
|
const omittedEventCounts = new Map<string, number>();
|
||||||
const collector = new PlaywrightScenarioNetwork({
|
const collector = new PlaywrightScenarioNetwork({
|
||||||
selection: selected,
|
selection: selected,
|
||||||
|
limits: scenario.capture.limits,
|
||||||
secrets,
|
secrets,
|
||||||
readTimeoutMs: 50,
|
readTimeoutMs: 50,
|
||||||
...(signal === undefined ? {} : { signal }),
|
...(signal === undefined ? {} : { signal }),
|
||||||
@@ -124,10 +129,38 @@ const harness = (
|
|||||||
events.push(
|
events.push(
|
||||||
browserScenarioEventSchema.parse({ ...event, sequence, step_index: 0 }),
|
browserScenarioEventSchema.parse({ ...event, sequence, step_index: 0 }),
|
||||||
);
|
);
|
||||||
return sequence;
|
return { status: "retained", sequence };
|
||||||
|
},
|
||||||
|
admitText: () => ({ status: "retained", sequence: events.length + 1 }),
|
||||||
|
admitContentBody: () => ({
|
||||||
|
status: "retained",
|
||||||
|
sequence: events.length + 1,
|
||||||
|
}),
|
||||||
|
omitNetwork: (transactionId, kind, reason) => {
|
||||||
|
let omission = networkOmissions.get(transactionId);
|
||||||
|
if (omission === undefined) {
|
||||||
|
omission = { kinds: new Set(), reasons: new Set() };
|
||||||
|
networkOmissions.set(transactionId, omission);
|
||||||
|
}
|
||||||
|
omission.kinds.add(kind);
|
||||||
|
omission.reasons.add(reason);
|
||||||
|
},
|
||||||
|
omitEvent: (kind) => {
|
||||||
|
omittedEventCounts.set(kind, (omittedEventCounts.get(kind) ?? 0) + 1);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { collector, events, secrets };
|
const networkOmissions = new Map<
|
||||||
|
string,
|
||||||
|
{ kinds: Set<string>; reasons: Set<string> }
|
||||||
|
>();
|
||||||
|
return {
|
||||||
|
collector,
|
||||||
|
events,
|
||||||
|
limits: scenario.capture.limits,
|
||||||
|
secrets,
|
||||||
|
networkOmissions,
|
||||||
|
omittedEventCounts,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyText = (event: BrowserScenarioEvent) => {
|
const bodyText = (event: BrowserScenarioEvent) => {
|
||||||
@@ -350,6 +383,144 @@ it("does not read unfinished streaming responses and reports the cutoff", async
|
|||||||
expect(collector.limitations().length).toBeGreaterThan(0);
|
expect(collector.limitations().length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("queues completed body reads behind the active-read limit", async () => {
|
||||||
|
const { collector, events } = harness({
|
||||||
|
request_body: false,
|
||||||
|
response_body: true,
|
||||||
|
header_values: false,
|
||||||
|
});
|
||||||
|
const requests = Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, index) => new RequestFixture(`request-${String(index)}`),
|
||||||
|
);
|
||||||
|
const releases: Array<(bytes: Buffer) => void> = [];
|
||||||
|
let activeReads = 0;
|
||||||
|
let maximumActiveReads = 0;
|
||||||
|
for (const request of requests) {
|
||||||
|
const response = new ResponseFixture(request, "unused");
|
||||||
|
response.read = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
activeReads += 1;
|
||||||
|
maximumActiveReads = Math.max(maximumActiveReads, activeReads);
|
||||||
|
releases.push((bytes) => {
|
||||||
|
activeReads -= 1;
|
||||||
|
resolve(bytes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
collector.request(request);
|
||||||
|
collector.response(response);
|
||||||
|
collector.finished(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
let releasedReads = 0;
|
||||||
|
while (releasedReads < requests.length) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
const batch = releases.splice(0);
|
||||||
|
expect(batch.length).toBeGreaterThan(0);
|
||||||
|
batch.forEach((release) => release(Buffer.from("captured")));
|
||||||
|
releasedReads += batch.length;
|
||||||
|
}
|
||||||
|
await collector.finish();
|
||||||
|
|
||||||
|
const content = events.filter(
|
||||||
|
(event) => event.kind === "network-content" && event.phase === "response",
|
||||||
|
);
|
||||||
|
expect(maximumActiveReads).toBe(16);
|
||||||
|
expect(content).toHaveLength(20);
|
||||||
|
expect(
|
||||||
|
content.every(
|
||||||
|
(event) =>
|
||||||
|
event.kind === "network-content" && event.body.state === "captured",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(events.filter(({ kind }) => kind === "network-content")).toHaveLength(
|
||||||
|
20,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts each queued body's timeout when its read begins", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { collector, events } = harness({
|
||||||
|
request_body: false,
|
||||||
|
response_body: true,
|
||||||
|
header_values: false,
|
||||||
|
});
|
||||||
|
const requests = Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, index) => new RequestFixture(`request-${String(index)}`),
|
||||||
|
);
|
||||||
|
for (const request of requests) {
|
||||||
|
const response = new ResponseFixture(request, "unused");
|
||||||
|
response.read = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(Buffer.from("captured")), 40);
|
||||||
|
});
|
||||||
|
collector.request(request);
|
||||||
|
collector.response(response);
|
||||||
|
collector.finished(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishing = collector.finish();
|
||||||
|
await vi.advanceTimersByTimeAsync(40);
|
||||||
|
await vi.advanceTimersByTimeAsync(40);
|
||||||
|
await finishing;
|
||||||
|
|
||||||
|
const content = events.filter(
|
||||||
|
(event) => event.kind === "network-content" && event.phase === "response",
|
||||||
|
);
|
||||||
|
expect(content).toHaveLength(20);
|
||||||
|
expect(
|
||||||
|
content.every(
|
||||||
|
(event) =>
|
||||||
|
event.kind === "network-content" && event.body.state === "captured",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles queued reads as cancelled without starting their body APIs", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const { collector, events } = harness(
|
||||||
|
{
|
||||||
|
request_body: false,
|
||||||
|
response_body: true,
|
||||||
|
header_values: false,
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
const requests = Array.from(
|
||||||
|
{ length: 20 },
|
||||||
|
(_, index) => new RequestFixture(`request-${String(index)}`),
|
||||||
|
);
|
||||||
|
const responses = requests.map((request) => {
|
||||||
|
const response = new ResponseFixture(request, "unused");
|
||||||
|
response.read = () => new Promise(() => undefined);
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
requests.forEach((request, index) => {
|
||||||
|
collector.request(request);
|
||||||
|
collector.response(responses[index]!);
|
||||||
|
collector.finished(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
|
||||||
|
controller.abort();
|
||||||
|
await collector.finish();
|
||||||
|
|
||||||
|
expect(responses.filter(({ bodyReads }) => bodyReads > 0)).toHaveLength(16);
|
||||||
|
const content = events.filter(
|
||||||
|
(event) => event.kind === "network-content" && event.phase === "response",
|
||||||
|
);
|
||||||
|
expect(content).toHaveLength(20);
|
||||||
|
expect(
|
||||||
|
content.every(
|
||||||
|
(event) =>
|
||||||
|
event.kind === "network-content" &&
|
||||||
|
event.body.state === "unavailable" &&
|
||||||
|
event.body.reason === "cancelled",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds hung completed-body reads and rejects late mutation after finalization", async () => {
|
it("bounds hung completed-body reads and rejects late mutation after finalization", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const { collector, events } = harness();
|
const { collector, events } = harness();
|
||||||
@@ -554,9 +725,9 @@ it("bounds admitted transactions before invoking content producers", async () =>
|
|||||||
await collector.finish();
|
await collector.finish();
|
||||||
expect(admitted[0]?.bodyReads).toBe(1);
|
expect(admitted[0]?.bodyReads).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
admitted
|
admitted.every(
|
||||||
.slice(16)
|
(request) => request.bodyReads === 1 && request.headerReads === 1,
|
||||||
.every((request) => request.bodyReads === 0 && request.headerReads === 0),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(rejected.bodyReads).toBe(0);
|
expect(rejected.bodyReads).toBe(0);
|
||||||
expect(rejected.headerReads).toBe(0);
|
expect(rejected.headerReads).toBe(0);
|
||||||
@@ -568,6 +739,55 @@ it("bounds admitted transactions before invoking content producers", async () =>
|
|||||||
expect(collector.limitations().join(" ")).toContain("resource budget");
|
expect(collector.limitations().join(" ")).toContain("resource budget");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the caller's transaction limit and reports the exact omitted ID range", async () => {
|
||||||
|
const { collector, events, omittedEventCounts } = harness(
|
||||||
|
selection,
|
||||||
|
undefined,
|
||||||
|
"fixture",
|
||||||
|
{ max_network_transactions: 2 },
|
||||||
|
);
|
||||||
|
const requests = Array.from(
|
||||||
|
{ length: 4 },
|
||||||
|
(_, index) => new RequestFixture(`body-${String(index)}`),
|
||||||
|
);
|
||||||
|
for (const request of requests) collector.request(request);
|
||||||
|
collector.response(new ResponseFixture(requests[2]!, "body-2"));
|
||||||
|
collector.finished(requests[2]!);
|
||||||
|
await collector.finish();
|
||||||
|
expect(collector.omittedTransactionRange()).toEqual({
|
||||||
|
first_transaction_id: "request-3",
|
||||||
|
last_transaction_id: "request-4",
|
||||||
|
count: 2,
|
||||||
|
reason: "max-network-transactions",
|
||||||
|
});
|
||||||
|
expect(events.filter(({ kind }) => kind === "request")).toHaveLength(2);
|
||||||
|
expect(Object.fromEntries(omittedEventCounts)).toEqual({
|
||||||
|
request: 2,
|
||||||
|
"network-content": 3,
|
||||||
|
response: 1,
|
||||||
|
"request-finished": 1,
|
||||||
|
});
|
||||||
|
expect(requests[2]?.bodyReads).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the caller's body-byte limit and returns typed size evidence", async () => {
|
||||||
|
const { collector, events } = harness(selection, undefined, "fixture", {
|
||||||
|
max_network_body_bytes: 4,
|
||||||
|
});
|
||||||
|
const request = new RequestFixture("12345");
|
||||||
|
collector.request(request);
|
||||||
|
await collector.finish();
|
||||||
|
expect(events.find(({ kind }) => kind === "network-content")).toMatchObject({
|
||||||
|
transaction_id: "request-1",
|
||||||
|
body: {
|
||||||
|
state: "unavailable",
|
||||||
|
reason: "body-size-limit",
|
||||||
|
limit_bytes: 4,
|
||||||
|
observed_bytes: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps header names and selected body when unselected values are oversized", async () => {
|
it("keeps header names and selected body when unselected values are oversized", async () => {
|
||||||
const { collector, events } = harness({
|
const { collector, events } = harness({
|
||||||
request_body: true,
|
request_body: true,
|
||||||
@@ -605,6 +825,9 @@ it("omits oversized body copies and retains ordinary content", async () => {
|
|||||||
const content = events.filter((event) => event.kind === "network-content");
|
const content = events.filter((event) => event.kind === "network-content");
|
||||||
expect(content[0]?.body).toMatchObject({
|
expect(content[0]?.body).toMatchObject({
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
|
reason: "body-size-limit",
|
||||||
|
limit_bytes: 64 * 1024,
|
||||||
|
observed_bytes: 64 * 1024 + 1,
|
||||||
message: expect.stringContaining("budget"),
|
message: expect.stringContaining("budget"),
|
||||||
});
|
});
|
||||||
expect(bodyText(content[1]!)).toBe("ordinary");
|
expect(bodyText(content[1]!)).toBe("ordinary");
|
||||||
@@ -628,13 +851,24 @@ it("reserves redaction expansion before creating replacement copies", async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not read content when phase metadata was omitted", async () => {
|
it("does not read content when phase metadata was omitted", async () => {
|
||||||
const { secrets } = harness();
|
const { secrets, limits } = harness();
|
||||||
const redact = vi.spyOn(secrets, "redact");
|
const redact = vi.spyOn(secrets, "redact");
|
||||||
const collector = new PlaywrightScenarioNetwork({
|
const collector = new PlaywrightScenarioNetwork({
|
||||||
selection,
|
selection,
|
||||||
|
limits,
|
||||||
secrets,
|
secrets,
|
||||||
admitText: () => false,
|
admitText: () => ({
|
||||||
emit: () => 0,
|
status: "omitted",
|
||||||
|
sequence: 1,
|
||||||
|
reason: "max-total-event-allocation-bytes",
|
||||||
|
}),
|
||||||
|
emit: (): BrowserScenarioEventAdmission => ({
|
||||||
|
status: "retained",
|
||||||
|
sequence: 1,
|
||||||
|
}),
|
||||||
|
admitContentBody: () => ({ status: "retained", sequence: 1 }),
|
||||||
|
omitNetwork: () => undefined,
|
||||||
|
omitEvent: () => undefined,
|
||||||
});
|
});
|
||||||
const request = new RequestFixture("ordinary");
|
const request = new RequestFixture("ordinary");
|
||||||
collector.request(request);
|
collector.request(request);
|
||||||
@@ -642,7 +876,7 @@ it("does not read content when phase metadata was omitted", async () => {
|
|||||||
expect(redact).not.toHaveBeenCalled();
|
expect(redact).not.toHaveBeenCalled();
|
||||||
expect(request.bodyReads + request.headerReads).toBe(0);
|
expect(request.bodyReads + request.headerReads).toBe(0);
|
||||||
expect(collector.limitations().join(" ")).toContain(
|
expect(collector.limitations().join(" ")).toContain(
|
||||||
"aggregate browser event budget",
|
"max-total-event-allocation-bytes",
|
||||||
);
|
);
|
||||||
expect(collector.limitations().join(" ")).toContain("incomplete");
|
expect(collector.limitations().join(" ")).toContain("incomplete");
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import type {
|
import type {
|
||||||
BrowserNetworkBody,
|
BrowserNetworkBody,
|
||||||
|
BrowserNetworkHeaders,
|
||||||
BrowserNetworkContentSelection,
|
BrowserNetworkContentSelection,
|
||||||
} from "../../domain/browserNetworkEvidence.js";
|
} from "../../domain/browserNetworkEvidence.js";
|
||||||
|
import type { BrowserScenarioCaptureLimits } from "../../domain/browserScenarioCaptureLimits.js";
|
||||||
|
import type {
|
||||||
|
BrowserScenarioEventAdmission,
|
||||||
|
BrowserScenarioEventBudgetReason,
|
||||||
|
} from "../../domain/browserScenarioCaptureValues.js";
|
||||||
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
|
import type { BrowserScenarioEvent } from "../../domain/browserScenarioCaptureValues.js";
|
||||||
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
|
import type { BrowserScenarioSecrets } from "../BrowserScenarioSecrets.js";
|
||||||
import {
|
import {
|
||||||
captureNetworkBody,
|
captureNetworkBody,
|
||||||
captureNetworkHeaders,
|
captureNetworkHeaders,
|
||||||
|
type NetworkBodyCapture,
|
||||||
type NetworkHeadersSource,
|
type NetworkHeadersSource,
|
||||||
type NetworkReadOptions,
|
type NetworkReadOptions,
|
||||||
} from "./PlaywrightNetworkContent.js";
|
} from "./PlaywrightNetworkContent.js";
|
||||||
@@ -56,12 +63,42 @@ interface Transaction {
|
|||||||
requestObserved: boolean;
|
requestObserved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface QueuedContentRead {
|
||||||
|
readonly run: () => Promise<void>;
|
||||||
|
readonly resolve: () => void;
|
||||||
|
readonly reject: (cause: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface NetworkCaptureOptions {
|
interface NetworkCaptureOptions {
|
||||||
readonly selection: BrowserNetworkContentSelection;
|
readonly selection: BrowserNetworkContentSelection;
|
||||||
|
readonly limits: BrowserScenarioCaptureLimits;
|
||||||
readonly secrets: BrowserScenarioSecrets;
|
readonly secrets: BrowserScenarioSecrets;
|
||||||
readonly emit: (event: UnindexedNetworkEvent) => number;
|
readonly emit: (
|
||||||
|
event: UnindexedNetworkEvent,
|
||||||
|
) => BrowserScenarioEventAdmission;
|
||||||
/** Check aggregate event capacity before redacting producer-owned metadata. */
|
/** Check aggregate event capacity before redacting producer-owned metadata. */
|
||||||
readonly admitText?: (values: Iterable<string>) => boolean;
|
readonly admitText: (
|
||||||
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
|
values: Iterable<string>,
|
||||||
|
) => BrowserScenarioEventAdmission;
|
||||||
|
readonly admitContentBody: (input: {
|
||||||
|
readonly transactionId: string;
|
||||||
|
readonly sourceEventSequence: number;
|
||||||
|
readonly phase: "request" | "response";
|
||||||
|
readonly headers: BrowserNetworkHeaders;
|
||||||
|
readonly observedBytes: number;
|
||||||
|
readonly allocationBytes: number;
|
||||||
|
readonly mediaType: string | undefined;
|
||||||
|
}) => BrowserScenarioEventAdmission;
|
||||||
|
readonly omitNetwork: (
|
||||||
|
transactionId: string,
|
||||||
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
|
reason: BrowserScenarioEventBudgetReason,
|
||||||
|
) => void;
|
||||||
|
readonly omitEvent: (
|
||||||
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
|
reason: BrowserScenarioEventBudgetReason,
|
||||||
|
) => void;
|
||||||
readonly signal?: AbortSignal;
|
readonly signal?: AbortSignal;
|
||||||
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
|
/** Production uses 5s; a fixture can supply its own bounded-read clock. */
|
||||||
readonly readTimeoutMs?: number;
|
readonly readTimeoutMs?: number;
|
||||||
@@ -73,15 +110,33 @@ export class PlaywrightScenarioNetwork {
|
|||||||
ScenarioNetworkRequest,
|
ScenarioNetworkRequest,
|
||||||
Transaction
|
Transaction
|
||||||
>();
|
>();
|
||||||
|
private readonly omittedRequests = new WeakSet<ScenarioNetworkRequest>();
|
||||||
|
private readonly omittedPhases = new WeakMap<
|
||||||
|
ScenarioNetworkRequest,
|
||||||
|
Set<string>
|
||||||
|
>();
|
||||||
|
private totalTransactions = 0;
|
||||||
|
private omittedTransactionCount = 0;
|
||||||
|
private firstOmittedTransaction = 0;
|
||||||
|
private lastOmittedTransaction = 0;
|
||||||
|
/** One request and one response-content job at most per admitted transaction. */
|
||||||
private readonly pending: Promise<void>[] = [];
|
private readonly pending: Promise<void>[] = [];
|
||||||
|
private readonly queuedReads: QueuedContentRead[] = [];
|
||||||
private readonly gaps = new Set<string>();
|
private readonly gaps = new Set<string>();
|
||||||
private accepting = true;
|
private accepting = true;
|
||||||
private activeReads = 0;
|
private activeReads = 0;
|
||||||
private readonly readOptions: NetworkReadOptions;
|
private readonly readOptions: NetworkReadOptions;
|
||||||
|
private readonly limits: BrowserScenarioCaptureLimits;
|
||||||
|
|
||||||
constructor(private readonly options: NetworkCaptureOptions) {
|
constructor(private readonly options: NetworkCaptureOptions) {
|
||||||
|
this.limits = options.limits;
|
||||||
this.readOptions = {
|
this.readOptions = {
|
||||||
timeoutMs: options.readTimeoutMs ?? 5_000,
|
timeoutMs: options.readTimeoutMs ?? 5_000,
|
||||||
|
maxBodyBytes: this.limits.max_network_body_bytes,
|
||||||
|
maxBodyAllocationBytes: this.limits.max_network_body_allocation_bytes,
|
||||||
|
maxHeaderAllocationBytes: this.limits.max_network_header_allocation_bytes,
|
||||||
|
maxMetadataAllocationBytes:
|
||||||
|
this.limits.max_network_metadata_allocation_bytes,
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,30 +145,59 @@ export class PlaywrightScenarioNetwork {
|
|||||||
request(request: ScenarioNetworkRequest): void {
|
request(request: ScenarioNetworkRequest): void {
|
||||||
if (!this.accepting) return;
|
if (!this.accepting) return;
|
||||||
const transaction = this.transaction(request);
|
const transaction = this.transaction(request);
|
||||||
if (transaction === undefined || transaction.requestObserved) return;
|
if (transaction === undefined) {
|
||||||
if (!this.admitMetadata(request)) return;
|
this.omitOmittedTransactionPhase(request, "request");
|
||||||
|
if (
|
||||||
|
this.options.selection.request_body ||
|
||||||
|
this.options.selection.header_values
|
||||||
|
)
|
||||||
|
this.omitOmittedTransactionContent(request, "request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (transaction.requestObserved) return;
|
||||||
|
if (!this.admitMetadata(transaction, "request", request)) return;
|
||||||
transaction.requestObserved = true;
|
transaction.requestObserved = true;
|
||||||
const sequence = this.options.emit({
|
const admission = this.options.emit({
|
||||||
...this.metadata(request, transaction),
|
...this.metadata(request, transaction),
|
||||||
kind: "request",
|
kind: "request",
|
||||||
status: null,
|
status: null,
|
||||||
failure: null,
|
failure: null,
|
||||||
});
|
});
|
||||||
if (sequence === 0) return;
|
if (admission.status === "omitted") {
|
||||||
|
this.options.omitNetwork(transaction.id, "request", admission.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = admission.sequence;
|
||||||
if (
|
if (
|
||||||
this.options.selection.request_body ||
|
this.options.selection.request_body ||
|
||||||
this.options.selection.header_values
|
this.options.selection.header_values
|
||||||
)
|
)
|
||||||
this.collect(transaction, sequence, "request", request, () =>
|
this.collect(
|
||||||
this.options.selection.request_body
|
transaction,
|
||||||
? captureNetworkBody(
|
sequence,
|
||||||
async () => request.postDataBuffer(),
|
"request",
|
||||||
"request",
|
request,
|
||||||
request,
|
(headers) =>
|
||||||
this.options.secrets,
|
this.options.selection.request_body
|
||||||
this.readOptions,
|
? captureNetworkBody(
|
||||||
)
|
async () => request.postDataBuffer(),
|
||||||
: Promise.resolve({ state: "not_requested" }),
|
"request",
|
||||||
|
request,
|
||||||
|
this.options.secrets,
|
||||||
|
this.readOptions,
|
||||||
|
(body) =>
|
||||||
|
this.options.admitContentBody({
|
||||||
|
transactionId: transaction.id,
|
||||||
|
sourceEventSequence: sequence,
|
||||||
|
phase: "request",
|
||||||
|
headers,
|
||||||
|
observedBytes: body.observedBytes,
|
||||||
|
allocationBytes: body.allocationBytes,
|
||||||
|
mediaType: body.mediaType,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: Promise.resolve({ state: "not_requested" }),
|
||||||
|
this.options.selection.header_values,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +206,15 @@ export class PlaywrightScenarioNetwork {
|
|||||||
if (!this.accepting) return;
|
if (!this.accepting) return;
|
||||||
const request = response.request();
|
const request = response.request();
|
||||||
const transaction = this.transaction(request);
|
const transaction = this.transaction(request);
|
||||||
|
if (transaction === undefined) {
|
||||||
|
this.omitOmittedTransactionPhase(request, "response");
|
||||||
|
this.observeOmittedTransactionPhase(request, "response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
transaction === undefined ||
|
|
||||||
!this.admitMetadata(
|
!this.admitMetadata(
|
||||||
|
transaction,
|
||||||
|
"response",
|
||||||
request,
|
request,
|
||||||
undefined,
|
undefined,
|
||||||
[],
|
[],
|
||||||
@@ -132,7 +222,7 @@ export class PlaywrightScenarioNetwork {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const sequence = this.options.emit({
|
const admission = this.options.emit({
|
||||||
...this.metadata(request, transaction),
|
...this.metadata(request, transaction),
|
||||||
kind: "response",
|
kind: "response",
|
||||||
url: this.options.secrets.sanitizeUrl(response.url()),
|
url: this.options.secrets.sanitizeUrl(response.url()),
|
||||||
@@ -140,7 +230,11 @@ export class PlaywrightScenarioNetwork {
|
|||||||
header_names: this.headerNames(response),
|
header_names: this.headerNames(response),
|
||||||
failure: null,
|
failure: null,
|
||||||
});
|
});
|
||||||
if (sequence === 0) return;
|
if (admission.status === "omitted") {
|
||||||
|
this.options.omitNetwork(transaction.id, "response", admission.reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sequence = admission.sequence;
|
||||||
transaction.response = { source: response, sequence };
|
transaction.response = { source: response, sequence };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +242,31 @@ export class PlaywrightScenarioNetwork {
|
|||||||
finished(request: ScenarioNetworkRequest): void {
|
finished(request: ScenarioNetworkRequest): void {
|
||||||
if (!this.accepting) return;
|
if (!this.accepting) return;
|
||||||
const transaction = this.transaction(request);
|
const transaction = this.transaction(request);
|
||||||
if (transaction === undefined || !this.admitMetadata(request)) return;
|
if (transaction === undefined) {
|
||||||
|
this.omitOmittedTransactionPhase(request, "request-finished");
|
||||||
|
const observed = this.omittedPhases.get(request);
|
||||||
|
if (
|
||||||
|
observed?.has("response") &&
|
||||||
|
(this.options.selection.response_body ||
|
||||||
|
this.options.selection.header_values)
|
||||||
|
)
|
||||||
|
this.omitOmittedTransactionContent(request, "response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.admitMetadata(transaction, "request-finished", request)) return;
|
||||||
transaction.terminal = "finished";
|
transaction.terminal = "finished";
|
||||||
const sequence = this.options.emit({
|
const admission = this.options.emit({
|
||||||
...this.metadata(request, transaction),
|
...this.metadata(request, transaction),
|
||||||
kind: "request-finished",
|
kind: "request-finished",
|
||||||
status: null,
|
status: null,
|
||||||
failure: null,
|
failure: null,
|
||||||
});
|
});
|
||||||
if (sequence === 0) {
|
if (admission.status === "omitted") {
|
||||||
|
this.options.omitNetwork(
|
||||||
|
transaction.id,
|
||||||
|
"request-finished",
|
||||||
|
admission.reason,
|
||||||
|
);
|
||||||
transaction.responseScheduled = true;
|
transaction.responseScheduled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,18 +277,31 @@ export class PlaywrightScenarioNetwork {
|
|||||||
failed(request: ScenarioNetworkRequest): void {
|
failed(request: ScenarioNetworkRequest): void {
|
||||||
if (!this.accepting) return;
|
if (!this.accepting) return;
|
||||||
const transaction = this.transaction(request);
|
const transaction = this.transaction(request);
|
||||||
if (transaction === undefined) return;
|
if (transaction === undefined) {
|
||||||
|
this.omitOmittedTransactionPhase(request, "request-failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
transaction.terminal = "failed";
|
transaction.terminal = "failed";
|
||||||
const failure = request.failure()?.errorText || "Unknown request failure";
|
const failure = request.failure()?.errorText || "Unknown request failure";
|
||||||
if (!this.admitMetadata(request, undefined, [failure])) return;
|
if (
|
||||||
|
!this.admitMetadata(transaction, "request-failed", request, undefined, [
|
||||||
|
failure,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return;
|
||||||
const message = this.options.secrets.redact(failure);
|
const message = this.options.secrets.redact(failure);
|
||||||
const sequence = this.options.emit({
|
const admission = this.options.emit({
|
||||||
...this.metadata(request, transaction),
|
...this.metadata(request, transaction),
|
||||||
kind: "request-failed",
|
kind: "request-failed",
|
||||||
status: null,
|
status: null,
|
||||||
failure: message,
|
failure: message,
|
||||||
});
|
});
|
||||||
if (sequence === 0) {
|
if (admission.status === "omitted") {
|
||||||
|
this.options.omitNetwork(
|
||||||
|
transaction.id,
|
||||||
|
"request-failed",
|
||||||
|
admission.reason,
|
||||||
|
);
|
||||||
transaction.responseScheduled = true;
|
transaction.responseScheduled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -189,20 +312,21 @@ export class PlaywrightScenarioNetwork {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop observing, mark unfinished transactions, and settle bounded content reads. */
|
/** Stop observing, mark unfinished transactions, and drain admitted content reads. */
|
||||||
async finish(): Promise<void> {
|
async finish(): Promise<void> {
|
||||||
if (!this.accepting) {
|
if (!this.accepting) {
|
||||||
await Promise.all(this.pending);
|
await this.settlePendingReads();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.accepting = false;
|
this.accepting = false;
|
||||||
for (const [request, transaction] of this.transactions) {
|
for (const [request, transaction] of this.transactions) {
|
||||||
if (transaction.terminal === undefined) {
|
if (transaction.terminal === undefined) {
|
||||||
if (!this.admitMetadata(request)) continue;
|
if (!this.admitMetadata(transaction, "request-unfinished", request))
|
||||||
|
continue;
|
||||||
this.gaps.add(
|
this.gaps.add(
|
||||||
"Some network requests had not completed when capture ended; later activity is unknown.",
|
"Some network requests had not completed when capture ended; later activity is unknown.",
|
||||||
);
|
);
|
||||||
const sequence = this.options.emit({
|
const admission = this.options.emit({
|
||||||
...this.metadata(request, transaction),
|
...this.metadata(request, transaction),
|
||||||
kind: "request-unfinished",
|
kind: "request-unfinished",
|
||||||
status: null,
|
status: null,
|
||||||
@@ -212,7 +336,12 @@ export class PlaywrightScenarioNetwork {
|
|||||||
? "cancelled"
|
? "cancelled"
|
||||||
: "capture-ended",
|
: "capture-ended",
|
||||||
});
|
});
|
||||||
if (sequence === 0) {
|
if (admission.status === "omitted") {
|
||||||
|
this.options.omitNetwork(
|
||||||
|
transaction.id,
|
||||||
|
"request-unfinished",
|
||||||
|
admission.reason,
|
||||||
|
);
|
||||||
transaction.responseScheduled = true;
|
transaction.responseScheduled = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -227,7 +356,15 @@ export class PlaywrightScenarioNetwork {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all(this.pending);
|
await this.settlePendingReads();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async settlePendingReads(): Promise<void> {
|
||||||
|
const results = await Promise.allSettled(this.pending);
|
||||||
|
const failed = results.find(
|
||||||
|
(result): result is PromiseRejectedResult => result.status === "rejected",
|
||||||
|
);
|
||||||
|
if (failed !== undefined) throw failed.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Missing selected content contributes to aggregate completeness. */
|
/** Missing selected content contributes to aggregate completeness. */
|
||||||
@@ -235,14 +372,38 @@ export class PlaywrightScenarioNetwork {
|
|||||||
return [...this.gaps];
|
return [...this.gaps];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
omittedTransactionRange(): {
|
||||||
|
readonly first_transaction_id: string;
|
||||||
|
readonly last_transaction_id: string;
|
||||||
|
readonly count: number;
|
||||||
|
readonly reason: "max-network-transactions";
|
||||||
|
} | null {
|
||||||
|
return this.omittedTransactionCount === 0
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
first_transaction_id: `request-${String(this.firstOmittedTransaction)}`,
|
||||||
|
last_transaction_id: `request-${String(this.lastOmittedTransaction)}`,
|
||||||
|
count: this.omittedTransactionCount,
|
||||||
|
reason: "max-network-transactions",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private transaction(
|
private transaction(
|
||||||
request: ScenarioNetworkRequest,
|
request: ScenarioNetworkRequest,
|
||||||
): Transaction | undefined {
|
): Transaction | undefined {
|
||||||
const known = this.transactions.get(request);
|
const known = this.transactions.get(request);
|
||||||
if (known !== undefined) return known;
|
if (known !== undefined) return known;
|
||||||
if (this.transactions.size >= 1024) {
|
if (this.omittedRequests.has(request)) return undefined;
|
||||||
|
this.totalTransactions += 1;
|
||||||
|
const id = `request-${String(this.totalTransactions)}`;
|
||||||
|
if (this.transactions.size >= this.limits.max_network_transactions) {
|
||||||
|
this.omittedRequests.add(request);
|
||||||
|
if (this.omittedTransactionCount === 0)
|
||||||
|
this.firstOmittedTransaction = this.totalTransactions;
|
||||||
|
this.lastOmittedTransaction = this.totalTransactions;
|
||||||
|
this.omittedTransactionCount += 1;
|
||||||
this.gaps.add(
|
this.gaps.add(
|
||||||
"Network collection reached its 1024-transaction resource budget; further transactions were omitted.",
|
`Network collection reached its ${String(this.limits.max_network_transactions)}-transaction resource budget; omitted transaction IDs are listed in events.omissions.network_transaction_range.`,
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -250,7 +411,7 @@ export class PlaywrightScenarioNetwork {
|
|||||||
const predecessorTransaction =
|
const predecessorTransaction =
|
||||||
predecessor === null ? undefined : this.transactions.get(predecessor);
|
predecessor === null ? undefined : this.transactions.get(predecessor);
|
||||||
const transaction: Transaction = {
|
const transaction: Transaction = {
|
||||||
id: `request-${this.transactions.size + 1}`,
|
id,
|
||||||
redirectedFrom: predecessorTransaction?.id ?? null,
|
redirectedFrom: predecessorTransaction?.id ?? null,
|
||||||
redirectedFromState:
|
redirectedFromState:
|
||||||
predecessor === null
|
predecessor === null
|
||||||
@@ -265,27 +426,58 @@ export class PlaywrightScenarioNetwork {
|
|||||||
return transaction;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
private admitProducerStrings(values: Iterable<string>): boolean {
|
private admitProducerStrings(
|
||||||
const collected: string[] = [];
|
transaction: Transaction,
|
||||||
let cost = 0;
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
for (const value of values) {
|
values: Iterable<string>,
|
||||||
cost += this.options.secrets.redactionAllocationCost(value.length) + 128;
|
): boolean {
|
||||||
if (cost > 1024 * 1024) {
|
const admission = this.options.admitText(kind, values);
|
||||||
this.gaps.add(
|
if (admission.status === "retained") return true;
|
||||||
"Some network metadata exceeded its allocation budget and was omitted.",
|
this.options.omitNetwork(transaction.id, kind, admission.reason);
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
collected.push(value);
|
|
||||||
}
|
|
||||||
if (this.options.admitText?.(collected) ?? true) return true;
|
|
||||||
this.gaps.add(
|
this.gaps.add(
|
||||||
"Some network metadata was omitted because the aggregate browser event budget was reached; affected transactions are incomplete.",
|
`Network ${kind} metadata was omitted for ${transaction.id} because ${admission.reason} was reached; network coverage is incomplete.`,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private omitOmittedTransactionPhase(
|
||||||
|
request: ScenarioNetworkRequest,
|
||||||
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
|
): void {
|
||||||
|
if (!this.omittedRequests.has(request)) return;
|
||||||
|
if (!this.observeOmittedTransactionPhase(request, kind)) return;
|
||||||
|
this.options.omitEvent(kind, "max-network-transactions");
|
||||||
|
}
|
||||||
|
|
||||||
|
private observeOmittedTransactionPhase(
|
||||||
|
request: ScenarioNetworkRequest,
|
||||||
|
phase: string,
|
||||||
|
): boolean {
|
||||||
|
let observed = this.omittedPhases.get(request);
|
||||||
|
if (observed === undefined) {
|
||||||
|
observed = new Set();
|
||||||
|
this.omittedPhases.set(request, observed);
|
||||||
|
}
|
||||||
|
if (observed.has(phase)) return false;
|
||||||
|
observed.add(phase);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private omitOmittedTransactionContent(
|
||||||
|
request: ScenarioNetworkRequest,
|
||||||
|
phase: "request" | "response",
|
||||||
|
): void {
|
||||||
|
if (!this.omittedRequests.has(request)) return;
|
||||||
|
if (
|
||||||
|
!this.observeOmittedTransactionPhase(request, `network-content:${phase}`)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.options.omitEvent("network-content", "max-network-transactions");
|
||||||
|
}
|
||||||
|
|
||||||
private admitMetadata(
|
private admitMetadata(
|
||||||
|
transaction: Transaction,
|
||||||
|
kind: UnindexedNetworkEvent["kind"],
|
||||||
source: NetworkHeadersSource,
|
source: NetworkHeadersSource,
|
||||||
url?: string,
|
url?: string,
|
||||||
additionalStrings: readonly string[] = [],
|
additionalStrings: readonly string[] = [],
|
||||||
@@ -295,6 +487,8 @@ export class PlaywrightScenarioNetwork {
|
|||||||
}[] = [],
|
}[] = [],
|
||||||
): boolean {
|
): boolean {
|
||||||
return this.admitProducerStrings(
|
return this.admitProducerStrings(
|
||||||
|
transaction,
|
||||||
|
kind,
|
||||||
this.metadataStrings(source, url, additionalStrings, additionalSources),
|
this.metadataStrings(source, url, additionalStrings, additionalSources),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -379,7 +573,7 @@ export class PlaywrightScenarioNetwork {
|
|||||||
response.sequence,
|
response.sequence,
|
||||||
"response",
|
"response",
|
||||||
response.source,
|
response.source,
|
||||||
() =>
|
(headers) =>
|
||||||
this.options.selection.response_body
|
this.options.selection.response_body
|
||||||
? unavailable === undefined
|
? unavailable === undefined
|
||||||
? captureNetworkBody(
|
? captureNetworkBody(
|
||||||
@@ -388,9 +582,20 @@ export class PlaywrightScenarioNetwork {
|
|||||||
response.source,
|
response.source,
|
||||||
this.options.secrets,
|
this.options.secrets,
|
||||||
this.readOptions,
|
this.readOptions,
|
||||||
|
(body) =>
|
||||||
|
this.options.admitContentBody({
|
||||||
|
transactionId: transaction.id,
|
||||||
|
sourceEventSequence: response.sequence,
|
||||||
|
phase: "response",
|
||||||
|
headers,
|
||||||
|
observedBytes: body.observedBytes,
|
||||||
|
allocationBytes: body.allocationBytes,
|
||||||
|
mediaType: body.mediaType,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
: Promise.resolve(unavailable)
|
: Promise.resolve(unavailable)
|
||||||
: Promise.resolve({ state: "not_requested" }),
|
: Promise.resolve({ state: "not_requested" }),
|
||||||
|
this.options.selection.header_values,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,54 +604,69 @@ export class PlaywrightScenarioNetwork {
|
|||||||
sequence: number,
|
sequence: number,
|
||||||
phase: "request" | "response",
|
phase: "request" | "response",
|
||||||
source: NetworkHeadersSource,
|
source: NetworkHeadersSource,
|
||||||
body: () => Promise<BrowserNetworkBody>,
|
body: (headers: BrowserNetworkHeaders) => Promise<NetworkBodyCapture>,
|
||||||
|
includeHeaders: boolean,
|
||||||
): void {
|
): void {
|
||||||
if (this.activeReads >= 16) {
|
const collect = async (): Promise<void> => {
|
||||||
const message = "Network content exceeds the 16 concurrent-read budget.";
|
const retainedHeaders = includeHeaders
|
||||||
this.gaps.add(message);
|
? await captureNetworkHeaders(
|
||||||
this.options.emit({
|
source,
|
||||||
|
this.options.secrets,
|
||||||
|
this.readOptions,
|
||||||
|
)
|
||||||
|
: { state: "not_requested" as const };
|
||||||
|
const retainedBody = await body(retainedHeaders);
|
||||||
|
if (retainedBody.state === "omitted") {
|
||||||
|
this.options.omitNetwork(
|
||||||
|
transaction.id,
|
||||||
|
"network-content",
|
||||||
|
retainedBody.reason,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
retainedHeaders.state === "unavailable" ||
|
||||||
|
retainedBody.state === "unavailable" ||
|
||||||
|
retainedBody.state === "not_exposed"
|
||||||
|
)
|
||||||
|
this.gaps.add(
|
||||||
|
"Some selected network content was unavailable; per-transaction content states retain the reason.",
|
||||||
|
);
|
||||||
|
const admission = this.options.emit({
|
||||||
kind: "network-content",
|
kind: "network-content",
|
||||||
transaction_id: transaction.id,
|
transaction_id: transaction.id,
|
||||||
source_event_sequence: sequence,
|
source_event_sequence: sequence,
|
||||||
phase,
|
phase,
|
||||||
headers: this.options.selection.header_values
|
headers: retainedHeaders,
|
||||||
? { state: "unavailable", message }
|
body: retainedBody,
|
||||||
: { state: "not_requested" },
|
|
||||||
body: this.options.selection[
|
|
||||||
phase === "request" ? "request_body" : "response_body"
|
|
||||||
]
|
|
||||||
? { state: "unavailable", reason: "body-read-failed", message }
|
|
||||||
: { state: "not_requested" },
|
|
||||||
});
|
});
|
||||||
return;
|
if (admission.status === "omitted")
|
||||||
}
|
this.options.omitNetwork(
|
||||||
this.activeReads += 1;
|
transaction.id,
|
||||||
const headers = this.options.selection.header_values
|
"network-content",
|
||||||
? captureNetworkHeaders(source, this.options.secrets, this.readOptions)
|
admission.reason,
|
||||||
: Promise.resolve({ state: "not_requested" } as const);
|
);
|
||||||
|
};
|
||||||
this.pending.push(
|
this.pending.push(
|
||||||
Promise.all([headers, body()])
|
new Promise<void>((resolve, reject) => {
|
||||||
.then(([retainedHeaders, retainedBody]) => {
|
this.queuedReads.push({ run: collect, resolve, reject });
|
||||||
if (
|
this.pumpContentReads();
|
||||||
retainedHeaders.state === "unavailable" ||
|
}),
|
||||||
retainedBody.state === "unavailable" ||
|
|
||||||
retainedBody.state === "not_exposed"
|
|
||||||
)
|
|
||||||
this.gaps.add(
|
|
||||||
"Some selected network content was unavailable; per-transaction content states retain the reason.",
|
|
||||||
);
|
|
||||||
this.options.emit({
|
|
||||||
kind: "network-content",
|
|
||||||
transaction_id: transaction.id,
|
|
||||||
source_event_sequence: sequence,
|
|
||||||
phase,
|
|
||||||
headers: retainedHeaders,
|
|
||||||
body: retainedBody,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.activeReads -= 1;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pumpContentReads(): void {
|
||||||
|
while (this.activeReads < 16 && this.queuedReads.length > 0) {
|
||||||
|
const job = this.queuedReads.shift();
|
||||||
|
if (job === undefined) return;
|
||||||
|
this.activeReads += 1;
|
||||||
|
void job
|
||||||
|
.run()
|
||||||
|
.then(job.resolve, job.reject)
|
||||||
|
.finally(() => {
|
||||||
|
this.activeReads -= 1;
|
||||||
|
this.pumpContentReads();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+24
-5
@@ -1,5 +1,6 @@
|
|||||||
import { z } from "incur";
|
import { z } from "incur";
|
||||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||||
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
|
import { AdbDeviceAnalysisService } from "../application/adb/AdbDeviceAnalysisService.js";
|
||||||
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
|
import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysisPort.js";
|
||||||
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
|
import { createAdbDeviceAnalysisProvider } from "../composition/adb.js";
|
||||||
@@ -351,12 +352,30 @@ export const registerAdbCommands = (
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) => {
|
run: ({ args, options }) => {
|
||||||
const extras = options.extra.map((entry) => {
|
const extras: {
|
||||||
|
readonly type: "string" | "boolean" | "int" | "long" | "float";
|
||||||
|
readonly key: string;
|
||||||
|
readonly value: string;
|
||||||
|
}[] = [];
|
||||||
|
for (const [index, entry] of options.extra.entries()) {
|
||||||
const first = entry.indexOf(":");
|
const first = entry.indexOf(":");
|
||||||
const second = entry.indexOf(":", first + 1);
|
const second = entry.indexOf(":", first + 1);
|
||||||
if (first === -1 || second === -1)
|
if (first === -1 || second === -1)
|
||||||
throw new Error(`--extra expects type:key:value, received: ${entry}`);
|
return logCliCommand(
|
||||||
return {
|
logger,
|
||||||
|
CLI_COMMANDS.startAdbActivity,
|
||||||
|
async () => {
|
||||||
|
throw new AnalysisInputError("start_adb_activity", undefined, [
|
||||||
|
{
|
||||||
|
path: ["extras", index],
|
||||||
|
reason: "invalid_format",
|
||||||
|
message: "Expected --extra in type:key:value form.",
|
||||||
|
expected: "type:key:value",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
extras.push({
|
||||||
type: entry.slice(0, first) as
|
type: entry.slice(0, first) as
|
||||||
| "string"
|
| "string"
|
||||||
| "boolean"
|
| "boolean"
|
||||||
@@ -365,8 +384,8 @@ export const registerAdbCommands = (
|
|||||||
| "float",
|
| "float",
|
||||||
key: entry.slice(first + 1, second),
|
key: entry.slice(first + 1, second),
|
||||||
value: entry.slice(second + 1),
|
value: entry.slice(second + 1),
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
|
return execute(CLI_COMMANDS.startAdbActivity, "start_adb_activity", {
|
||||||
serial: args.serial,
|
serial: args.serial,
|
||||||
action: args.action,
|
action: args.action,
|
||||||
|
|||||||
@@ -45,10 +45,18 @@ export const registerCutterCommands = (
|
|||||||
.refine((value) => !/[\r\n]/u.test(value)),
|
.refine((value) => !/[\r\n]/u.test(value)),
|
||||||
}),
|
}),
|
||||||
options: z.object({
|
options: z.object({
|
||||||
|
jsonOutput: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Return command-specific JSON output when available (independent of CLI output formatting).",
|
||||||
|
),
|
||||||
json: z
|
json: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.optional()
|
||||||
.describe("Return command-specific JSON output when available."),
|
.describe(
|
||||||
|
"Deprecated compatibility input: use --json-output; pass --json=true or --json=false (bare --json formats CLI output).",
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
@@ -58,7 +66,7 @@ export const registerCutterCommands = (
|
|||||||
session_id: args.sessionId,
|
session_id: args.sessionId,
|
||||||
expected_generation: args.expectedGeneration,
|
expected_generation: args.expectedGeneration,
|
||||||
command: args.command,
|
command: args.command,
|
||||||
json: options.json,
|
json: options.jsonOutput ?? options.json ?? false,
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -215,6 +215,21 @@ export const registerFridaCommands = (
|
|||||||
run: ({ args, options }) =>
|
run: ({ args, options }) =>
|
||||||
withCommandCancellation((signal) =>
|
withCommandCancellation((signal) =>
|
||||||
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
|
logCliCommand(logger, CLI_COMMANDS.instrumentWithFrida, async () => {
|
||||||
|
if (args.mode === "attach" && options.argument !== undefined)
|
||||||
|
return projectAnalysisError(
|
||||||
|
new AnalysisInputError(
|
||||||
|
CLI_COMMANDS.instrumentWithFrida,
|
||||||
|
undefined,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: ["argument"],
|
||||||
|
reason: "invalid_value",
|
||||||
|
message:
|
||||||
|
"--argument is only valid in spawn mode; remove it when attaching to a process.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
(options.script === undefined) ===
|
(options.script === undefined) ===
|
||||||
(options.scriptFile === undefined)
|
(options.scriptFile === undefined)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const API_SURFACE_EXAMPLE = {
|
|||||||
source: {
|
source: {
|
||||||
kind: "retained-evidence",
|
kind: "retained-evidence",
|
||||||
evidence_id:
|
evidence_id:
|
||||||
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
|
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
kind: "api-surface",
|
kind: "api-surface",
|
||||||
@@ -14,12 +14,12 @@ export const API_SURFACE_EXAMPLE = {
|
|||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
parent_evidence_id:
|
parent_evidence_id:
|
||||||
"ev_9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
|
"ev_90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
|
||||||
parent_operation: "inspect_web_network_capture",
|
parent_operation: "inspect_web_network_capture",
|
||||||
parent_digest:
|
parent_digest:
|
||||||
"9e218c18bc6159ec9c8492a9130c286496fb1b6d9f03ec2f4ea3321ce2b15097",
|
"90073e9478eada1e2cc351b523e6ba9231c9b4bc9708773567b880094db9d4c7",
|
||||||
view_digest:
|
view_digest:
|
||||||
"47c1859b7a86dd10770061767300bdb6ec177a9254e8dc7863d5ea8603343da6",
|
"c9494341920dc4cff6ecc6d8ed2c5e3abf15976642c253248e220d45f290ca7b",
|
||||||
artifact: {
|
artifact: {
|
||||||
path: "/fixtures/api-surface.har",
|
path: "/fixtures/api-surface.har",
|
||||||
sha256:
|
sha256:
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
import {
|
||||||
|
artifactGraphDigest,
|
||||||
|
artifactIdForContent,
|
||||||
|
artifactManifestId,
|
||||||
|
occurrenceIdForLocation,
|
||||||
|
} from "../domain/artifactIdentity.js";
|
||||||
|
import type {
|
||||||
|
ArtifactEdge,
|
||||||
|
ArtifactNode,
|
||||||
|
ArtifactOccurrence,
|
||||||
|
IntegrityContradiction,
|
||||||
|
} from "../domain/artifactGraph.js";
|
||||||
import { createEvidence } from "../domain/evidence.js";
|
import { createEvidence } from "../domain/evidence.js";
|
||||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||||
|
|
||||||
const inventory = (digit: string) => {
|
const inventory = (digit: string) => {
|
||||||
const sha = digit.repeat(64);
|
const sha = digit.repeat(64);
|
||||||
const artifactId = `art_${digestCanonicalValue({ sha256: sha }, "Artifact example")}`;
|
const artifactId = artifactIdForContent(sha);
|
||||||
const occurrenceId = `occ_${digestCanonicalValue({ root: artifactId }, "Artifact example")}`;
|
const nodes: ArtifactNode[] = [
|
||||||
const nodes = [
|
|
||||||
{
|
{
|
||||||
artifact_id: artifactId,
|
artifact_id: artifactId,
|
||||||
format: "file",
|
format: "file",
|
||||||
@@ -18,9 +28,13 @@ const inventory = (digit: string) => {
|
|||||||
limitations: [],
|
limitations: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const occurrences = [
|
const occurrences: ArtifactOccurrence[] = [
|
||||||
{
|
{
|
||||||
occurrence_id: occurrenceId,
|
occurrence_id: occurrenceIdForLocation({
|
||||||
|
rootArtifactId: artifactId,
|
||||||
|
logicalPath: ".",
|
||||||
|
entryKind: "file",
|
||||||
|
}),
|
||||||
artifact_id: artifactId,
|
artifact_id: artifactId,
|
||||||
parent_occurrence_id: null,
|
parent_occurrence_id: null,
|
||||||
logical_path: ".",
|
logical_path: ".",
|
||||||
@@ -36,36 +50,31 @@ const inventory = (digit: string) => {
|
|||||||
limitations: [],
|
limitations: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const graphSha256 = digestCanonicalValue(
|
const edges: ArtifactEdge[] = [];
|
||||||
{
|
const integrityContradictions: IntegrityContradiction[] = [];
|
||||||
nodes,
|
const graphSha256 = artifactGraphDigest({
|
||||||
occurrences,
|
nodes,
|
||||||
edges: [],
|
occurrences,
|
||||||
integrity_contradictions: [],
|
edges,
|
||||||
},
|
contradictions: integrityContradictions,
|
||||||
"Artifact example",
|
});
|
||||||
);
|
|
||||||
return jsonValueSchema.parse({
|
return jsonValueSchema.parse({
|
||||||
manifest: {
|
manifest: {
|
||||||
manifest_id: `agm_${digestCanonicalValue(
|
manifest_id: artifactManifestId(artifactId, graphSha256),
|
||||||
{
|
|
||||||
root_artifact_id: artifactId,
|
|
||||||
graph_sha256: graphSha256,
|
|
||||||
},
|
|
||||||
"Artifact example",
|
|
||||||
)}`,
|
|
||||||
root_artifact_id: artifactId,
|
root_artifact_id: artifactId,
|
||||||
root_sha256: sha,
|
root_sha256: sha,
|
||||||
root_format: "file",
|
root_format: "file",
|
||||||
graph_sha256: graphSha256,
|
graph_sha256: graphSha256,
|
||||||
node_count: nodes.length,
|
node_count: nodes.length,
|
||||||
occurrence_count: occurrences.length,
|
occurrence_count: occurrences.length,
|
||||||
edge_count: 0,
|
edge_count: edges.length,
|
||||||
},
|
},
|
||||||
nodes,
|
nodes,
|
||||||
occurrences,
|
occurrences,
|
||||||
edges: [],
|
edges,
|
||||||
provenance: [],
|
provenance: [],
|
||||||
|
integrity_contradictions: integrityContradictions,
|
||||||
|
coverage: { scope: "root", status: "complete" },
|
||||||
limitations: [],
|
limitations: [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const BROWSER_SCENARIO_TOOL_CONTRACTS = [
|
|||||||
name: "capture_browser_scenario",
|
name: "capture_browser_scenario",
|
||||||
...toolContractMetadata("capture_browser_scenario"),
|
...toolContractMetadata("capture_browser_scenario"),
|
||||||
description:
|
description:
|
||||||
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, and a URL-only final capture are defaults. capture.network selects request_body, response_body, and header_values independently; selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
|
"Run a controlled browser scenario when passive observation cannot exercise the application. Launch the selected executable or connect to a loopback CDP target, provide a start URL and actions, and select storage or artifacts when needed. Environment, empty storage, a URL-only final capture, and measured event-retention budgets are defaults. capture.network selects request_body, response_body, and header_values independently; capture.limits lets callers raise or lower the event count, retained-allocation estimates, network transaction count, and network content budgets. Selected content is returned inline with transaction and event references, byte digests, and explicit unavailable states. Budget omissions identify affected event kinds and network transactions. Credentials and declared secrets are redacted. Agents can run their own local fixture servers to control application responses. Actions run until completion or cancellation unless timeout_ms is set. Results are returned inline.",
|
||||||
kind: "browser-provider",
|
kind: "browser-provider",
|
||||||
inputSchema: browserScenarioInputSchema,
|
inputSchema: browserScenarioInputSchema,
|
||||||
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
outputSchema: evidenceResultOf(browserScenarioCaptureSchema),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
browserCaptureComparisonInputSchema,
|
browserCaptureComparisonInputSchema,
|
||||||
browserCaptureComparisonSchema,
|
browserCaptureComparisonSchema,
|
||||||
} from "../domain/browserCaptureComparison.js";
|
} from "../domain/browserCaptureComparison.js";
|
||||||
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "../domain/browserScenarioCaptureLimits.js";
|
||||||
import {
|
import {
|
||||||
captureWebScreenshotInputSchema,
|
captureWebScreenshotInputSchema,
|
||||||
compareWebScreenshotsInputSchema,
|
compareWebScreenshotsInputSchema,
|
||||||
@@ -85,6 +86,7 @@ const exampleScenarioCapture = () => ({
|
|||||||
header_values: false,
|
header_values: false,
|
||||||
},
|
},
|
||||||
event_families: [],
|
event_families: [],
|
||||||
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
},
|
},
|
||||||
duration_ms: 10,
|
duration_ms: 10,
|
||||||
steps: [
|
steps: [
|
||||||
@@ -117,7 +119,16 @@ const exampleScenarioCapture = () => ({
|
|||||||
completeness: scenarioCompleteness,
|
completeness: scenarioCompleteness,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
events: { retained: 0, dropped: 0, items: [] },
|
events: {
|
||||||
|
retained: 0,
|
||||||
|
dropped: 0,
|
||||||
|
items: [],
|
||||||
|
omissions: {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
completeness: scenarioCompleteness,
|
completeness: scenarioCompleteness,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const SESSION_TOOL_CONTRACTS = [
|
|||||||
),
|
),
|
||||||
session(
|
session(
|
||||||
"compare_artifacts",
|
"compare_artifacts",
|
||||||
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right. Every delta cites both inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
|
"Compare complete artifact inventories by logical occurrence path, content identity, metadata, and graph relations. Pass the inventory Evidence nested in each inspect_artifact result as left and right, or pass its inspect_analysis_view summary while the complete parent Evidence remains retained in this session. Summary facts are not treated as a complete inventory; comparison resolves the exact retained parent. Every delta cites both complete inputs, and gaps yield truncated or unknown, never equivalence. Returns every change inline.",
|
||||||
artifactComparisonInputSchema,
|
artifactComparisonInputSchema,
|
||||||
),
|
),
|
||||||
session(
|
session(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
listArchitecturesSchema,
|
listArchitecturesSchema,
|
||||||
} from "../domain/native/nativeInspection.js";
|
} from "../domain/native/nativeInspection.js";
|
||||||
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
|
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
|
||||||
import { artifactInspectionOutputSchema } from "../domain/artifactInspection.js";
|
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
|
||||||
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
|
||||||
import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
|
import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
|
||||||
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
|
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
|
||||||
@@ -404,7 +404,7 @@ export const nativeOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
|||||||
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
export const artifactOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||||
inspect_artifact: evidenceResultOf(
|
inspect_artifact: evidenceResultOf(
|
||||||
z.union([
|
z.union([
|
||||||
artifactInspectionOutputSchema,
|
artifactInspectionResultSchema,
|
||||||
artifactInventorySummaryViewResultSchema,
|
artifactInventorySummaryViewResultSchema,
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -278,11 +278,11 @@ export class CutterBridgeClient {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined);
|
: undefined);
|
||||||
this.#directory =
|
this.#directory = resolveBridgeDirectory(
|
||||||
configured ??
|
configured,
|
||||||
(this.#platform === "win32" && localAppData
|
this.#platform,
|
||||||
? join(localAppData, "REA", "CutterBridge")
|
localAppData,
|
||||||
: join(homedir(), ".cache", "rea", "cutter-bridge"));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listSessions(): Promise<CutterBridgeDiscovery> {
|
async listSessions(): Promise<CutterBridgeDiscovery> {
|
||||||
@@ -889,3 +889,21 @@ export class CutterBridgeClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveBridgeDirectory = (
|
||||||
|
configured: string | undefined,
|
||||||
|
platform: NodeJS.Platform,
|
||||||
|
localAppData: string | undefined,
|
||||||
|
): string => {
|
||||||
|
if (configured !== undefined && configured !== "") {
|
||||||
|
if (configured === "~") return homedir();
|
||||||
|
if (configured.startsWith("~/"))
|
||||||
|
return join(homedir(), configured.slice(2));
|
||||||
|
if (platform === "win32" && configured.startsWith("~\\"))
|
||||||
|
return join(homedir(), configured.slice(2));
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return platform === "win32" && localAppData
|
||||||
|
? join(localAppData, "REA", "CutterBridge")
|
||||||
|
: join(homedir(), ".cache", "rea", "cutter-bridge");
|
||||||
|
};
|
||||||
@@ -35,6 +35,11 @@ import type { AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
|||||||
export const analysisErrorRemediationAction = (
|
export const analysisErrorRemediationAction = (
|
||||||
error: AnalysisError,
|
error: AnalysisError,
|
||||||
): string => {
|
): string => {
|
||||||
|
if (
|
||||||
|
error instanceof BrowserObservationError &&
|
||||||
|
error.reason === "service_worker_blocked"
|
||||||
|
)
|
||||||
|
return "Set environment.service_workers to allow to permit this capture, or remove the service worker registration through the browser. REA never removes existing registrations.";
|
||||||
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
|
if (error instanceof AnalysisCapabilityUnavailableError && error.retryable)
|
||||||
return "Review details.reason, then retry the same analysis after the condition clears.";
|
return "Review details.reason, then retry the same analysis after the condition clears.";
|
||||||
if (error instanceof EvidenceFileError && error.published !== undefined)
|
if (error instanceof EvidenceFileError && error.published !== undefined)
|
||||||
@@ -177,6 +182,7 @@ const browserErrorCategory = (
|
|||||||
if (
|
if (
|
||||||
reason === "target_not_found" ||
|
reason === "target_not_found" ||
|
||||||
reason === "target_not_allowed" ||
|
reason === "target_not_allowed" ||
|
||||||
|
reason === "service_worker_blocked" ||
|
||||||
reason === "target_changed" ||
|
reason === "target_changed" ||
|
||||||
reason === "endpoint_unreachable" ||
|
reason === "endpoint_unreachable" ||
|
||||||
reason === "disconnected"
|
reason === "disconnected"
|
||||||
@@ -300,12 +306,14 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
|
|||||||
|
|
||||||
const RESTART_OWNED_PROVIDER =
|
const RESTART_OWNED_PROVIDER =
|
||||||
"Restart the owned provider by calling close_binary, then open the target again and retry.";
|
"Restart the owned provider by calling close_binary, then open the target again and retry.";
|
||||||
|
const RESTART_UNREACHABLE_HOPPER = `${RESTART_OWNED_PROVIDER} If Hopper may have exited, call export_evidence_bundle before close_binary; closing clears retained records.`;
|
||||||
|
|
||||||
const hopperProcessRemediation = (error: HopperProcessError): string => {
|
const hopperProcessRemediation = (error: HopperProcessError): string => {
|
||||||
if (error.failureCode !== undefined && error.userMessage !== undefined)
|
if (error.failureCode !== undefined && error.userMessage !== undefined)
|
||||||
return error.userMessage;
|
return error.userMessage;
|
||||||
if (error.stage === "launch")
|
if (error.stage === "launch")
|
||||||
return "Review the captured launcher diagnostics and Hopper setup, then retry opening the target.";
|
return "Review the captured launcher diagnostics and Hopper setup, then retry opening the target.";
|
||||||
|
if (error.providerState === "unreachable") return RESTART_UNREACHABLE_HOPPER;
|
||||||
if (providerRetryAction(error.providerState) === "restart_provider")
|
if (providerRetryAction(error.providerState) === "restart_provider")
|
||||||
return RESTART_OWNED_PROVIDER;
|
return RESTART_OWNED_PROVIDER;
|
||||||
return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider.";
|
return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider.";
|
||||||
@@ -335,7 +343,7 @@ const hopperProcessMessage = (error: HopperProcessError): string => {
|
|||||||
return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`;
|
return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`;
|
||||||
}
|
}
|
||||||
if (error.providerState === "unreachable")
|
if (error.providerState === "unreachable")
|
||||||
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_OWNED_PROVIDER}${startup}${request}`;
|
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_UNREACHABLE_HOPPER}${startup}${request}`;
|
||||||
return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`;
|
return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -392,7 +392,30 @@ it("retains native partial observations and cleanup uncertainty on provider fail
|
|||||||
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("analysis error projection: provider failures", () => {
|
describe("analysis error projection: browser provider failures", () => {
|
||||||
|
it("explains the service-worker policy refusal and names its recovery", () => {
|
||||||
|
const projected = projectAnalysisError(
|
||||||
|
new BrowserObservationError(
|
||||||
|
"capture_browser_scenario",
|
||||||
|
"service_worker_blocked",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "target_unavailable",
|
||||||
|
category: "unavailable",
|
||||||
|
retryable: false,
|
||||||
|
message: expect.stringContaining("service-worker policy is block"),
|
||||||
|
remediation: {
|
||||||
|
action: expect.stringContaining("environment.service_workers to allow"),
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
operation: "capture_browser_scenario",
|
||||||
|
reason: "service_worker_blocked",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("projects the primary browser failure alongside incomplete cleanup", () => {
|
it("projects the primary browser failure alongside incomplete cleanup", () => {
|
||||||
const projected = projectAnalysisError(
|
const projected = projectAnalysisError(
|
||||||
new BrowserObservationError(
|
new BrowserObservationError(
|
||||||
@@ -421,7 +444,9 @@ describe("analysis error projection: provider failures", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analysis error projection: Hopper provider failures", () => {
|
||||||
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
|
it("reports a timed-out active Hopper request and actionable retry guidance", () => {
|
||||||
const projected = projectAnalysisError(
|
const projected = projectAnalysisError(
|
||||||
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
|
new HopperTimeoutError(30_000, "trace_feature", 42, "busy"),
|
||||||
@@ -487,7 +512,9 @@ describe("analysis error projection: provider failures", () => {
|
|||||||
message: expect.stringContaining("Hopper analysis failed"),
|
message: expect.stringContaining("Hopper analysis failed"),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analysis error projection: detached provider diagnostics", () => {
|
||||||
it.each(["adapter", "cancelled", "timeout"])(
|
it.each(["adapter", "cancelled", "timeout"])(
|
||||||
"preserves detached, actionable diagnostics for %s errors",
|
"preserves detached, actionable diagnostics for %s errors",
|
||||||
(kind) => {
|
(kind) => {
|
||||||
@@ -515,7 +542,9 @@ describe("analysis error projection: provider failures", () => {
|
|||||||
expect(error.diagnostics?.exit_code).toBe(1);
|
expect(error.diagnostics?.exit_code).toBe(1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("analysis error projection: Hopper startup failures", () => {
|
||||||
it("maps the Linux startup compatibility exit codes without host internals", () => {
|
it("maps the Linux startup compatibility exit codes without host internals", () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
[70, "private_display_unavailable"],
|
[70, "private_display_unavailable"],
|
||||||
@@ -765,6 +794,18 @@ describe("analysis error projection: operational diagnostics", () => {
|
|||||||
expect(unknown.message).toContain("could not determine");
|
expect(unknown.message).toContain("could not determine");
|
||||||
expect(unknown.message).not.toContain("stopped");
|
expect(unknown.message).not.toContain("stopped");
|
||||||
expect(unknown.remediation.action).toContain("provider_operation_health");
|
expect(unknown.remediation.action).toContain("provider_operation_health");
|
||||||
|
|
||||||
|
const unreachable = projectAnalysisError(
|
||||||
|
new HopperProcessError(null, {
|
||||||
|
operation: "search_strings",
|
||||||
|
providerState: "unreachable",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(unreachable.message).toContain(
|
||||||
|
"If Hopper may have exited, call export_evidence_bundle before close_binary",
|
||||||
|
);
|
||||||
|
expect(unreachable.message).toContain("closing clears retained records");
|
||||||
|
expect(unreachable.remediation.action).toContain("export_evidence_bundle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const browserErrorCode = (
|
|||||||
if (
|
if (
|
||||||
reason === "target_not_found" ||
|
reason === "target_not_found" ||
|
||||||
reason === "target_not_allowed" ||
|
reason === "target_not_allowed" ||
|
||||||
|
reason === "service_worker_blocked" ||
|
||||||
reason === "target_changed"
|
reason === "target_changed"
|
||||||
)
|
)
|
||||||
return "target_unavailable";
|
return "target_unavailable";
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ describe("analysis snapshot contract", () => {
|
|||||||
raw_result: null,
|
raw_result: null,
|
||||||
provider: binding.provider,
|
provider: binding.provider,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
|
relationship_coverage: null,
|
||||||
locations: [],
|
locations: [],
|
||||||
subject: target,
|
subject: target,
|
||||||
},
|
},
|
||||||
@@ -262,6 +263,7 @@ describe("analysis snapshot contract", () => {
|
|||||||
raw_result: null,
|
raw_result: null,
|
||||||
provider: binding.provider,
|
provider: binding.provider,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
|
relationship_coverage: null,
|
||||||
locations: [],
|
locations: [],
|
||||||
subject: {
|
subject: {
|
||||||
path: "a\u0000b",
|
path: "a\u0000b",
|
||||||
@@ -293,6 +295,7 @@ describe("analysis snapshot contract", () => {
|
|||||||
raw_result: null,
|
raw_result: null,
|
||||||
provider: binding.provider,
|
provider: binding.provider,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
|
relationship_coverage: null,
|
||||||
locations: [],
|
locations: [],
|
||||||
subject: null,
|
subject: null,
|
||||||
},
|
},
|
||||||
@@ -456,6 +459,7 @@ describe("analysis snapshot Evidence binding", () => {
|
|||||||
raw_result: rawResult,
|
raw_result: rawResult,
|
||||||
provider: binding.provider,
|
provider: binding.provider,
|
||||||
limitations: ["fixture limitation"],
|
limitations: ["fixture limitation"],
|
||||||
|
relationship_coverage: null,
|
||||||
locations: [{ kind: "address" as const, address: "0x1000" }],
|
locations: [{ kind: "address" as const, address: "0x1000" }],
|
||||||
subject,
|
subject,
|
||||||
},
|
},
|
||||||
@@ -480,11 +484,21 @@ describe("analysis snapshot Evidence binding", () => {
|
|||||||
workflow_entries: [],
|
workflow_entries: [],
|
||||||
evidence_bundle: createEvidenceBundle([evidence]),
|
evidence_bundle: createEvidenceBundle([evidence]),
|
||||||
};
|
};
|
||||||
const parsedLegacySnapshot = parseAnalysisSnapshot(snapshot);
|
const parsedSnapshot = parseAnalysisSnapshot(snapshot);
|
||||||
expect(
|
expect(
|
||||||
parsedLegacySnapshot.entries[0]?.execution.relationship_coverage,
|
parsedSnapshot.entries[0]?.execution.relationship_coverage,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
|
||||||
|
const missingCoverage = structuredClone(snapshot);
|
||||||
|
const missingCoverageEntry = missingCoverage.entries[0];
|
||||||
|
if (missingCoverageEntry === undefined)
|
||||||
|
throw new Error("snapshot entry missing");
|
||||||
|
Reflect.deleteProperty(
|
||||||
|
missingCoverageEntry.execution,
|
||||||
|
"relationship_coverage",
|
||||||
|
);
|
||||||
|
expect(() => parseAnalysisSnapshot(missingCoverage)).toThrow();
|
||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
parseAnalysisSnapshot({
|
parseAnalysisSnapshot({
|
||||||
...snapshot,
|
...snapshot,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ const entrySchema = z.object({
|
|||||||
raw_result: jsonValueSchema.nullable(),
|
raw_result: jsonValueSchema.nullable(),
|
||||||
provider: committedProviderSchema,
|
provider: committedProviderSchema,
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
|
relationship_coverage: relationshipCoverageSchema.nullable(),
|
||||||
locations: z.array(evidenceLocationSchema),
|
locations: z.array(evidenceLocationSchema),
|
||||||
subject: subjectSchema.nullable(),
|
subject: subjectSchema.nullable(),
|
||||||
}),
|
}),
|
||||||
@@ -160,7 +160,7 @@ const workflowEntrySchema = z.object({
|
|||||||
provider: committedProviderSchema,
|
provider: committedProviderSchema,
|
||||||
analysis_profile: analysisProfileSchema,
|
analysis_profile: analysisProfileSchema,
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
|
relationship_coverage: relationshipCoverageSchema.nullable(),
|
||||||
locations: z.array(evidenceLocationSchema),
|
locations: z.array(evidenceLocationSchema),
|
||||||
subject: subjectSchema.nullable(),
|
subject: subjectSchema.nullable(),
|
||||||
}),
|
}),
|
||||||
@@ -305,9 +305,7 @@ const snapshotContentDigest = (
|
|||||||
format: execution.subject.format,
|
format: execution.subject.format,
|
||||||
architecture: execution.subject.architecture,
|
architecture: execution.subject.architecture,
|
||||||
},
|
},
|
||||||
...(execution.relationship_coverage === null
|
relationship_coverage: execution.relationship_coverage,
|
||||||
? {}
|
|
||||||
: { relationship_coverage: execution.relationship_coverage }),
|
|
||||||
});
|
});
|
||||||
return contentDigest({
|
return contentDigest({
|
||||||
...metadata,
|
...metadata,
|
||||||
@@ -872,9 +870,7 @@ const evidenceMatchesWorkflowEntry = (
|
|||||||
const evidenceContentMetadata = (evidence: Evidence) => {
|
const evidenceContentMetadata = (evidence: Evidence) => {
|
||||||
return {
|
return {
|
||||||
limitations: evidence.limitations,
|
limitations: evidence.limitations,
|
||||||
...(evidence.relationship_coverage === null
|
relationship_coverage: evidence.relationship_coverage,
|
||||||
? {}
|
|
||||||
: { relationship_coverage: evidence.relationship_coverage }),
|
|
||||||
locations: evidence.locations,
|
locations: evidence.locations,
|
||||||
subject:
|
subject:
|
||||||
evidence.subject === null
|
evidence.subject === null
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { expect, it } from "vitest";
|
|||||||
import { functionDossierSchema } from "../hopperValues.js";
|
import { functionDossierSchema } from "../hopperValues.js";
|
||||||
import { ghidraFunctionDossier } from "../ghidraValues.fixture.js";
|
import { ghidraFunctionDossier } from "../ghidraValues.fixture.js";
|
||||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "../../contracts/javascript/javascriptRuntimeReconciliationExample.js";
|
||||||
|
import { ARTIFACT_COMPARISON_EXAMPLE } from "../../contracts/artifactComparisonExample.js";
|
||||||
import {
|
import {
|
||||||
analysisViewBindJavaScriptGraphs,
|
analysisViewBindJavaScriptGraphs,
|
||||||
analysisViewJavaScriptAnalysis,
|
analysisViewJavaScriptAnalysis,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
pageViewCoverage,
|
pageViewCoverage,
|
||||||
} from "./analysisViewCoverage.js";
|
} from "./analysisViewCoverage.js";
|
||||||
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
|
import { sourceMapReference } from "../javascript/javascriptApplicationGraph.fixture.js";
|
||||||
|
import { createArtifactInspection } from "../artifactInspection.js";
|
||||||
import { javascriptModulePath } from "./javascriptApplicationView.js";
|
import { javascriptModulePath } from "./javascriptApplicationView.js";
|
||||||
import {
|
import {
|
||||||
inspectAnalysisViewInputSchema,
|
inspectAnalysisViewInputSchema,
|
||||||
@@ -162,6 +164,28 @@ it("preserves native API limitations and unavailable residual unknowns", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects artifact summary views when inspection substep coverage count disagrees", () => {
|
||||||
|
const source = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||||
|
const inspection = createArtifactInspection(source);
|
||||||
|
const parent: AnalysisViewParent = {
|
||||||
|
evidenceId: `ev_${"a".repeat(64)}`,
|
||||||
|
operation: "inspect_artifact",
|
||||||
|
normalizedResult: {
|
||||||
|
...inspection,
|
||||||
|
coverage: {
|
||||||
|
...inspection.coverage,
|
||||||
|
substeps_completed: inspection.substeps.length + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subject: source.subject,
|
||||||
|
limitations: [],
|
||||||
|
};
|
||||||
|
expect(projectAnalysisView(parent, { kind: "summary" })).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisInputError" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("pages every retained dossier collection and the native API record", () => {
|
it("pages every retained dossier collection and the native API record", () => {
|
||||||
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
const original = functionDossierSchema.parse(ghidraFunctionDossier());
|
||||||
const page = (
|
const page = (
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import type { AnalysisError } from "../analysisErrorBase.js";
|
|||||||
import { uniqueSorted } from "../canonicalOrdering.js";
|
import { uniqueSorted } from "../canonicalOrdering.js";
|
||||||
import { err, ok, type Result } from "../result.js";
|
import { err, ok, type Result } from "../result.js";
|
||||||
import {
|
import {
|
||||||
parseArtifactInventoryEvidence,
|
parseArtifactInspectionInventoryResult,
|
||||||
parseArtifactInventoryResult,
|
parseArtifactInventoryResult,
|
||||||
validateArtifactInventorySubject,
|
|
||||||
type InventorySet,
|
type InventorySet,
|
||||||
} from "../artifactInventoryEvidence.js";
|
} from "../artifactInventoryEvidence.js";
|
||||||
import {
|
import type { ArtifactInspectionResult } from "../artifactInspection.js";
|
||||||
artifactInspectionResultSchema,
|
|
||||||
type ArtifactInspectionResult,
|
|
||||||
} from "../artifactInspection.js";
|
|
||||||
import type {
|
import type {
|
||||||
AnalysisViewParent,
|
AnalysisViewParent,
|
||||||
AnalysisViewRequest,
|
AnalysisViewRequest,
|
||||||
@@ -52,36 +48,14 @@ const parseSource = (
|
|||||||
evidenceIds: [parent.evidenceId],
|
evidenceIds: [parent.evidenceId],
|
||||||
inspection: null,
|
inspection: null,
|
||||||
});
|
});
|
||||||
const inspection = artifactInspectionResultSchema.parse(
|
const parsed = parseArtifactInspectionInventoryResult(
|
||||||
parent.normalizedResult,
|
parent.normalizedResult,
|
||||||
|
parent.subject ?? null,
|
||||||
);
|
);
|
||||||
for (const step of inspection.substeps) {
|
|
||||||
if (
|
|
||||||
step.evidence_id !== step.evidence.evidence_id ||
|
|
||||||
step.evidence.operation !== step.operation
|
|
||||||
)
|
|
||||||
throw new TypeError(
|
|
||||||
"Inspection substep identity differs from its source Evidence",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = parseArtifactInventoryEvidence(
|
|
||||||
inspection.substeps.map(({ evidence }) => evidence),
|
|
||||||
);
|
|
||||||
const manifest = parsed.inventory.manifest;
|
|
||||||
validateArtifactInventorySubject(parent.subject ?? null, manifest);
|
|
||||||
if (
|
|
||||||
inspection.subject.manifest_id !== manifest.manifest_id ||
|
|
||||||
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
|
|
||||||
inspection.subject.root_sha256 !== manifest.root_sha256 ||
|
|
||||||
inspection.subject.root_format !== manifest.root_format
|
|
||||||
)
|
|
||||||
throw new TypeError(
|
|
||||||
"Inspection subject differs from its inventory manifest",
|
|
||||||
);
|
|
||||||
return ok({
|
return ok({
|
||||||
inventory: parsed.inventory,
|
inventory: parsed.inventory,
|
||||||
evidenceIds: parsed.evidence.map(({ evidence_id }) => evidence_id),
|
evidenceIds: parsed.evidenceIds,
|
||||||
inspection,
|
inspection: parsed.inspection,
|
||||||
});
|
});
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
|
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ const inventoryEvidence = (
|
|||||||
occurrences: sortedOccurrences,
|
occurrences: sortedOccurrences,
|
||||||
edges: [],
|
edges: [],
|
||||||
provenance: [],
|
provenance: [],
|
||||||
coverage_status: "complete",
|
integrity_contradictions: [],
|
||||||
|
coverage: { scope: "container-entries", status: "complete" },
|
||||||
limitations: [],
|
limitations: [],
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -590,9 +590,11 @@ describe("AppKit keyed archive connectors", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("keyed archive unresolved connectors", () => {
|
describe("keyed archive unresolved connectors", () => {
|
||||||
// A rooted UIKit archive whose only connector is UID 3, with UID 1 as the
|
const rootedOutletArchive = (
|
||||||
// owner, UID 2 as the destination and UID 7 as the "view" label.
|
connection: Record<string, unknown>,
|
||||||
const rootedOutletArchive = (connection: Record<string, unknown>) => ({
|
connectionClass = "UIRuntimeOutletConnection",
|
||||||
|
label = "view",
|
||||||
|
) => ({
|
||||||
$archiver: "NSKeyedArchiver",
|
$archiver: "NSKeyedArchiver",
|
||||||
$version: 100000,
|
$version: 100000,
|
||||||
$objects: [
|
$objects: [
|
||||||
@@ -606,14 +608,10 @@ describe("keyed archive unresolved connectors", () => {
|
|||||||
},
|
},
|
||||||
{ $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] },
|
{ $classname: "UIView", $classes: ["UIView", "UIResponder", "NSObject"] },
|
||||||
{
|
{
|
||||||
$classname: "UIRuntimeOutletConnection",
|
$classname: connectionClass,
|
||||||
$classes: [
|
$classes: [connectionClass, "UIRuntimeConnection", "NSObject"],
|
||||||
"UIRuntimeOutletConnection",
|
|
||||||
"UIRuntimeConnection",
|
|
||||||
"NSObject",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
"view",
|
label,
|
||||||
{
|
{
|
||||||
$class: { CF$UID: 11 },
|
$class: { CF$UID: 11 },
|
||||||
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }],
|
"NS.objects": [{ CF$UID: 1 }, { CF$UID: 2 }, { CF$UID: 3 }],
|
||||||
@@ -664,6 +662,43 @@ describe("keyed archive unresolved connectors", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it("projects keyed UIKit event connections as actions and retains the mask", () => {
|
||||||
|
const raw = rootedOutletArchive(
|
||||||
|
{
|
||||||
|
UISource: { CF$UID: 1 },
|
||||||
|
UIDestination: { CF$UID: 2 },
|
||||||
|
UILabel: { CF$UID: 7 },
|
||||||
|
UIEventMask: 64,
|
||||||
|
},
|
||||||
|
"UIRuntimeEventConnection",
|
||||||
|
"tapped:",
|
||||||
|
);
|
||||||
|
expect(parseInterfaceBuilderRecords(raw).connections[0]).toMatchObject({
|
||||||
|
kind: "action",
|
||||||
|
source_id: "1",
|
||||||
|
destination_id: "2",
|
||||||
|
label: "tapped:",
|
||||||
|
attributes: { UIEventMask: 64 },
|
||||||
|
});
|
||||||
|
const analysis = project(raw);
|
||||||
|
expect(analysis.graph.nodes).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "action", name: "tapped:" }),
|
||||||
|
expect.objectContaining({ kind: "objc_selector", name: "tapped:" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(analysis.graph.edges).toContainEqual(
|
||||||
|
expect.objectContaining({ relation: "target_action" }),
|
||||||
|
);
|
||||||
|
expect(analysis.graph.coverage).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
facet: "connections:Main.nib",
|
||||||
|
status: "complete",
|
||||||
|
examined: 1,
|
||||||
|
omitted: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports a recognized connector with no resolvable owner as unresolved", () => {
|
it("reports a recognized connector with no resolvable owner as unresolved", () => {
|
||||||
const raw = rootedOutletArchive({
|
const raw = rootedOutletArchive({
|
||||||
@@ -686,6 +721,27 @@ describe("keyed archive unresolved connectors", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(analysis.graph.truncated).toBe(true);
|
expect(analysis.graph.truncated).toBe(true);
|
||||||
|
|
||||||
|
const eventWithoutSource = rootedOutletArchive(
|
||||||
|
{
|
||||||
|
UIDestination: { CF$UID: 2 },
|
||||||
|
UILabel: { CF$UID: 7 },
|
||||||
|
UIEventMask: 64,
|
||||||
|
},
|
||||||
|
"UIRuntimeEventConnection",
|
||||||
|
"tapped:",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
parseInterfaceBuilderRecords(eventWithoutSource).unresolvedConnections,
|
||||||
|
).toBe(1);
|
||||||
|
expect(project(eventWithoutSource).graph.coverage).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
facet: "connections:Main.nib",
|
||||||
|
status: "partial",
|
||||||
|
reason: "connection_unresolved",
|
||||||
|
omitted: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,8 @@ const classifyConnection = (
|
|||||||
): InterfaceBuilderConnection["kind"] => {
|
): InterfaceBuilderConnection["kind"] => {
|
||||||
const value = type.toLowerCase();
|
const value = type.toLowerCase();
|
||||||
if (value.includes("outlet")) return "outlet";
|
if (value.includes("outlet")) return "outlet";
|
||||||
if (value.includes("action")) return "action";
|
if (value.includes("action") || value.includes("eventconnection"))
|
||||||
|
return "action";
|
||||||
if (value.includes("segue")) return "segue";
|
if (value.includes("segue")) return "segue";
|
||||||
return "other";
|
return "other";
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/** Class-file identity used to disambiguate JVM CAFEBABE from Mach-O FAT. */
|
||||||
|
class ClassFileCursor {
|
||||||
|
private offset = 0;
|
||||||
|
private readonly view: DataView;
|
||||||
|
|
||||||
|
constructor(private readonly bytes: Uint8Array) {
|
||||||
|
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
get atEnd(): boolean {
|
||||||
|
return this.offset === this.bytes.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
u1(): number | null {
|
||||||
|
if (!this.has(1)) return null;
|
||||||
|
return this.bytes[this.offset++]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
u2(): number | null {
|
||||||
|
if (!this.has(2)) return null;
|
||||||
|
const value = this.view.getUint16(this.offset, false);
|
||||||
|
this.offset += 2;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
u4(): number | null {
|
||||||
|
if (!this.has(4)) return null;
|
||||||
|
const value = this.view.getUint32(this.offset, false);
|
||||||
|
this.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
skip(length: number): boolean {
|
||||||
|
if (!Number.isSafeInteger(length) || length < 0 || !this.has(length))
|
||||||
|
return false;
|
||||||
|
this.offset += length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private has(length: number): boolean {
|
||||||
|
return this.offset <= this.bytes.byteLength - length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConstantPool {
|
||||||
|
readonly tags: (number | null)[];
|
||||||
|
readonly first: (number | null)[];
|
||||||
|
readonly second: (number | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const readConstantPool = (
|
||||||
|
cursor: ClassFileCursor,
|
||||||
|
count: number,
|
||||||
|
): ConstantPool | null => {
|
||||||
|
const pool: ConstantPool = {
|
||||||
|
tags: Array.from({ length: count }, () => null),
|
||||||
|
first: Array.from({ length: count }, () => null),
|
||||||
|
second: Array.from({ length: count }, () => null),
|
||||||
|
};
|
||||||
|
for (let index = 1; index < count; index += 1) {
|
||||||
|
const tag = cursor.u1();
|
||||||
|
if (tag === null) return null;
|
||||||
|
if (!readConstant(cursor, tag, index, pool)) return null;
|
||||||
|
if (tag === 5 || tag === 6) index += 1;
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConstant = (
|
||||||
|
cursor: ClassFileCursor,
|
||||||
|
tag: number,
|
||||||
|
index: number,
|
||||||
|
pool: ConstantPool,
|
||||||
|
): boolean => {
|
||||||
|
switch (tag) {
|
||||||
|
case 1: {
|
||||||
|
const length = cursor.u2();
|
||||||
|
if (length === null || !cursor.skip(length)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
case 4:
|
||||||
|
if (cursor.u4() === null) return false;
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
case 6:
|
||||||
|
if (index + 1 >= pool.tags.length || !cursor.skip(8)) return false;
|
||||||
|
break;
|
||||||
|
case 7:
|
||||||
|
case 8:
|
||||||
|
case 16:
|
||||||
|
case 19:
|
||||||
|
case 20:
|
||||||
|
pool.first[index] = cursor.u2();
|
||||||
|
if (pool.first[index] === null) return false;
|
||||||
|
break;
|
||||||
|
case 9:
|
||||||
|
case 10:
|
||||||
|
case 11:
|
||||||
|
case 12:
|
||||||
|
case 17:
|
||||||
|
case 18:
|
||||||
|
pool.first[index] = cursor.u2();
|
||||||
|
pool.second[index] = cursor.u2();
|
||||||
|
if (pool.first[index] === null || pool.second[index] === null)
|
||||||
|
return false;
|
||||||
|
break;
|
||||||
|
case 15:
|
||||||
|
pool.first[index] = cursor.u1();
|
||||||
|
pool.second[index] = cursor.u2();
|
||||||
|
if (pool.first[index] === null || pool.second[index] === null)
|
||||||
|
return false;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pool.tags[index] = tag;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const poolHasTag = (
|
||||||
|
pool: ConstantPool,
|
||||||
|
index: number,
|
||||||
|
...tags: number[]
|
||||||
|
): boolean =>
|
||||||
|
Number.isInteger(index) && index > 0 && tags.includes(pool.tags[index] ?? -1);
|
||||||
|
|
||||||
|
const hasValidPoolReferences = (pool: ConstantPool, major: number): boolean => {
|
||||||
|
for (let index = 1; index < pool.tags.length; index += 1) {
|
||||||
|
const tag = pool.tags[index] ?? null;
|
||||||
|
const first = pool.first[index] ?? null;
|
||||||
|
const second = pool.second[index] ?? null;
|
||||||
|
if ([7, 8, 16, 19, 20].includes(tag ?? -1)) {
|
||||||
|
if (first === null || !poolHasTag(pool, first, 1)) return false;
|
||||||
|
if (
|
||||||
|
(tag === 16 && major < 51) ||
|
||||||
|
((tag === 19 || tag === 20) && major < 53)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
} else if ([9, 10, 11].includes(tag ?? -1)) {
|
||||||
|
if (
|
||||||
|
first === null ||
|
||||||
|
second === null ||
|
||||||
|
!poolHasTag(pool, first, 7) ||
|
||||||
|
!poolHasTag(pool, second, 12)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
} else if (tag === 12) {
|
||||||
|
if (
|
||||||
|
first === null ||
|
||||||
|
second === null ||
|
||||||
|
!poolHasTag(pool, first, 1) ||
|
||||||
|
!poolHasTag(pool, second, 1)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
} else if (tag === 15 && !validMethodHandle(pool, first, second, major)) {
|
||||||
|
return false;
|
||||||
|
} else if (tag === 17 || tag === 18) {
|
||||||
|
if (
|
||||||
|
second === null ||
|
||||||
|
!poolHasTag(pool, second, 12) ||
|
||||||
|
major < (tag === 17 ? 55 : 51)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validMethodHandle = (
|
||||||
|
pool: ConstantPool,
|
||||||
|
kind: number | null,
|
||||||
|
reference: number | null,
|
||||||
|
major: number,
|
||||||
|
): boolean => {
|
||||||
|
if (kind === null || reference === null || kind < 1 || kind > 9 || major < 51)
|
||||||
|
return false;
|
||||||
|
if (kind <= 4) return poolHasTag(pool, reference, 9);
|
||||||
|
if (kind === 5 || kind === 8) return poolHasTag(pool, reference, 10);
|
||||||
|
if (kind === 6 || kind === 7)
|
||||||
|
return poolHasTag(pool, reference, ...(major >= 52 ? [10, 11] : [10]));
|
||||||
|
return poolHasTag(pool, reference, 11);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readAttributes = (
|
||||||
|
cursor: ClassFileCursor,
|
||||||
|
pool: ConstantPool,
|
||||||
|
count: number,
|
||||||
|
): boolean => {
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const name = cursor.u2();
|
||||||
|
const length = cursor.u4();
|
||||||
|
if (
|
||||||
|
name === null ||
|
||||||
|
!poolHasTag(pool, name, 1) ||
|
||||||
|
length === null ||
|
||||||
|
!cursor.skip(length)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readMembers = (cursor: ClassFileCursor, pool: ConstantPool): boolean => {
|
||||||
|
const count = cursor.u2();
|
||||||
|
if (count === null) return false;
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
if (cursor.u2() === null) return false;
|
||||||
|
const name = cursor.u2();
|
||||||
|
const descriptor = cursor.u2();
|
||||||
|
const attributes = cursor.u2();
|
||||||
|
if (
|
||||||
|
name === null ||
|
||||||
|
descriptor === null ||
|
||||||
|
!poolHasTag(pool, name, 1) ||
|
||||||
|
!poolHasTag(pool, descriptor, 1) ||
|
||||||
|
attributes === null ||
|
||||||
|
!readAttributes(cursor, pool, attributes)
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Validate class-file framing and constant-pool references before FAT parsing. */
|
||||||
|
export const isJvmClassFile = (bytes: Uint8Array): boolean => {
|
||||||
|
const cursor = new ClassFileCursor(bytes);
|
||||||
|
if (cursor.u4() !== 0xcafebabe) return false;
|
||||||
|
const minor = cursor.u2();
|
||||||
|
const major = cursor.u2();
|
||||||
|
const poolCount = cursor.u2();
|
||||||
|
if (
|
||||||
|
minor === null ||
|
||||||
|
major === null ||
|
||||||
|
major < 45 ||
|
||||||
|
poolCount === null ||
|
||||||
|
poolCount === 0
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const pool = readConstantPool(cursor, poolCount);
|
||||||
|
if (pool === null || !hasValidPoolReferences(pool, major)) return false;
|
||||||
|
if (cursor.u2() === null) return false;
|
||||||
|
const thisClass = cursor.u2();
|
||||||
|
const superClass = cursor.u2();
|
||||||
|
if (
|
||||||
|
thisClass === null ||
|
||||||
|
!poolHasTag(pool, thisClass, 7) ||
|
||||||
|
superClass === null ||
|
||||||
|
(superClass !== 0 && !poolHasTag(pool, superClass, 7))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
const interfaces = cursor.u2();
|
||||||
|
if (interfaces === null || !cursor.skip(interfaces * 2)) return false;
|
||||||
|
if (!readMembers(cursor, pool) || !readMembers(cursor, pool)) return false;
|
||||||
|
const attributes = cursor.u2();
|
||||||
|
return (
|
||||||
|
attributes !== null &&
|
||||||
|
readAttributes(cursor, pool, attributes) &&
|
||||||
|
cursor.atEnd
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isJvmClassFile } from "./jvmClassFile.js";
|
||||||
|
|
||||||
/** Physical facts for one entry in a Mach-O FAT architecture table. */
|
/** Physical facts for one entry in a Mach-O FAT architecture table. */
|
||||||
export interface FatSliceDeclaration {
|
export interface FatSliceDeclaration {
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
@@ -68,6 +70,8 @@ export const readFatContainerHeader = (
|
|||||||
state: "malformed",
|
state: "malformed",
|
||||||
reason: "FAT file size is invalid or contradicts retained bytes",
|
reason: "FAT file size is invalid or contradicts retained bytes",
|
||||||
};
|
};
|
||||||
|
if (bytes.byteLength === fileSize && isJvmClassFile(bytes))
|
||||||
|
return { state: "not-fat" };
|
||||||
if (bytes.byteLength < 8) {
|
if (bytes.byteLength < 8) {
|
||||||
return fileSize < 8
|
return fileSize < 8
|
||||||
? { state: "malformed", reason: "FAT header is truncated" }
|
? { state: "malformed", reason: "FAT header is truncated" }
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe("artifact graph path and provenance fields", () => {
|
|||||||
effects: ["read"],
|
effects: ["read"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
coverage: { scope: "container-entries", status: "complete" },
|
||||||
integrity_contradictions: [],
|
integrity_contradictions: [],
|
||||||
limitations: [],
|
limitations: [],
|
||||||
});
|
});
|
||||||
@@ -69,4 +70,51 @@ describe("artifact graph path and provenance fields", () => {
|
|||||||
expect(parsed.provenance[0]?.arguments).toHaveLength(1_025);
|
expect(parsed.provenance[0]?.arguments).toHaveLength(1_025);
|
||||||
expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]);
|
expect(parsed.provenance[0]?.arguments[0]).toBe(arguments_[0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires current coverage and integrity facts and rejects the obsolete status alias", () => {
|
||||||
|
const inventory = {
|
||||||
|
manifest: {
|
||||||
|
manifest_id: `agm_${"b".repeat(64)}`,
|
||||||
|
root_artifact_id: `art_${"a".repeat(64)}`,
|
||||||
|
root_sha256: "c".repeat(64),
|
||||||
|
root_format: "plist",
|
||||||
|
graph_sha256: "d".repeat(64),
|
||||||
|
node_count: 1,
|
||||||
|
occurrence_count: 1,
|
||||||
|
edge_count: 0,
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
occurrences: [],
|
||||||
|
edges: [],
|
||||||
|
provenance: [],
|
||||||
|
integrity_contradictions: [],
|
||||||
|
limitations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(artifactInventoryResultSchema.safeParse(inventory).success).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const { integrity_contradictions: _facts, ...withoutIntegrityFacts } =
|
||||||
|
inventory;
|
||||||
|
const currentWithoutIntegrityFacts = {
|
||||||
|
...withoutIntegrityFacts,
|
||||||
|
coverage: { scope: "root", status: "complete" },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
artifactInventoryResultSchema.safeParse(currentWithoutIntegrityFacts)
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
artifactInventoryResultSchema.safeParse({
|
||||||
|
...currentWithoutIntegrityFacts,
|
||||||
|
integrity_contradictions: [],
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
artifactInventoryResultSchema.safeParse({
|
||||||
|
...inventory,
|
||||||
|
coverage_status: "complete",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -196,32 +196,18 @@ export type ArtifactInventoryCoverage = z.infer<
|
|||||||
typeof artifactInventoryCoverageSchema
|
typeof artifactInventoryCoverageSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Complete inventory observation containing the stable artifact graph. */
|
/** Current inventory observation containing the stable artifact graph. */
|
||||||
export const artifactInventoryResultSchema = z.object({
|
export const artifactInventoryResultSchema = z.strictObject({
|
||||||
manifest: artifactGraphManifestSchema,
|
manifest: artifactGraphManifestSchema,
|
||||||
nodes: z.array(artifactNodeSchema),
|
nodes: z.array(artifactNodeSchema),
|
||||||
occurrences: z.array(artifactOccurrenceSchema),
|
occurrences: z.array(artifactOccurrenceSchema),
|
||||||
edges: z.array(artifactEdgeSchema),
|
edges: z.array(artifactEdgeSchema),
|
||||||
provenance: z.array(artifactCommandSchema),
|
provenance: z.array(artifactCommandSchema),
|
||||||
integrity_contradictions: z.array(integrityContradictionSchema).default([]),
|
integrity_contradictions: z.array(integrityContradictionSchema),
|
||||||
// Omitted by older retained Evidence; the inventory parser owns its compatibility policy.
|
coverage: artifactInventoryCoverageSchema,
|
||||||
coverage: artifactInventoryCoverageSchema.optional(),
|
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
/** Optional only for persisted inventory Evidence written before this field. */
|
|
||||||
coverage_status: z.enum(["complete", "partial"]).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Current producers always declare coverage; historical Evidence inputs may omit it. */
|
|
||||||
export const artifactInventoryOutputSchema =
|
|
||||||
artifactInventoryResultSchema.safeExtend({
|
|
||||||
coverage: artifactInventoryCoverageSchema,
|
|
||||||
coverage_status:
|
|
||||||
artifactInventoryResultSchema.shape.coverage_status.unwrap(),
|
|
||||||
});
|
|
||||||
export type ArtifactInventoryOutput = z.infer<
|
|
||||||
typeof artifactInventoryOutputSchema
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** One safely materialized artifact beneath caller-approved output root. */
|
/** One safely materialized artifact beneath caller-approved output root. */
|
||||||
const extractedArtifactSchema = z.object({
|
const extractedArtifactSchema = z.object({
|
||||||
artifact_id: artifactIdSchema,
|
artifact_id: artifactIdSchema,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
artifactInventoryOutputSchema,
|
|
||||||
artifactInventoryResultSchema,
|
artifactInventoryResultSchema,
|
||||||
type ArtifactInventoryResult,
|
type ArtifactInventoryResult,
|
||||||
} from "./artifactGraph.js";
|
} from "./artifactGraph.js";
|
||||||
@@ -75,7 +74,10 @@ export const artifactInspectionResultSchema = z.strictObject({
|
|||||||
operation: z.literal("inventory_artifact"),
|
operation: z.literal("inventory_artifact"),
|
||||||
status: z.literal("completed"),
|
status: z.literal("completed"),
|
||||||
evidence_id: evidenceIdSchema,
|
evidence_id: evidenceIdSchema,
|
||||||
evidence: evidenceSchema,
|
evidence: evidenceSchema.safeExtend({
|
||||||
|
operation: z.literal("inventory_artifact"),
|
||||||
|
normalized_result: artifactInventoryResultSchema,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1),
|
.min(1),
|
||||||
@@ -86,7 +88,7 @@ export const artifactInspectionResultSchema = z.strictObject({
|
|||||||
next_probes: z.array(nextProbeSchema),
|
next_probes: z.array(nextProbeSchema),
|
||||||
coverage: z.strictObject({
|
coverage: z.strictObject({
|
||||||
status: z.enum(["complete-within-substeps", "partial", "truncated"]),
|
status: z.enum(["complete-within-substeps", "partial", "truncated"]),
|
||||||
substeps_completed: z.literal(1),
|
substeps_completed: z.number().int().min(1),
|
||||||
observations_retained: z.number().int().min(0),
|
observations_retained: z.number().int().min(0),
|
||||||
observations_omitted: z.number().int().min(0),
|
observations_omitted: z.number().int().min(0),
|
||||||
hypotheses_retained: z.number().int().min(0),
|
hypotheses_retained: z.number().int().min(0),
|
||||||
@@ -104,24 +106,6 @@ export type ArtifactInspectionResult = z.infer<
|
|||||||
typeof artifactInspectionResultSchema
|
typeof artifactInspectionResultSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
|
||||||
* The current producer guarantees a concrete inventory payload. Specialize
|
|
||||||
* its output without narrowing the historical inspection/Evidence input
|
|
||||||
* parser or applying inventory defaults inside an authenticated imported record.
|
|
||||||
*/
|
|
||||||
export const artifactInspectionOutputSchema =
|
|
||||||
artifactInspectionResultSchema.safeExtend({
|
|
||||||
substeps: z
|
|
||||||
.array(
|
|
||||||
artifactInspectionResultSchema.shape.substeps.element.safeExtend({
|
|
||||||
evidence: evidenceSchema.safeExtend({
|
|
||||||
normalized_result: artifactInventoryOutputSchema,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Project one successful inventory Evidence record into an inspection report. */
|
/** Project one successful inventory Evidence record into an inspection report. */
|
||||||
export const createArtifactInspection = (
|
export const createArtifactInspection = (
|
||||||
inventoryInput: unknown,
|
inventoryInput: unknown,
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { ARTIFACT_COMPARISON_EXAMPLE } from "../contracts/artifactComparisonExample.js";
|
||||||
|
import { createArtifactInspection } from "./artifactInspection.js";
|
||||||
|
import { parseArtifactInventoryEvidence } from "./artifactInventoryEvidence.js";
|
||||||
|
import { createEvidence } from "./evidence.js";
|
||||||
|
import { jsonValueSchema } from "./jsonValue.js";
|
||||||
|
|
||||||
|
const inventoryEvidence = ARTIFACT_COMPARISON_EXAMPLE.left;
|
||||||
|
const target = inventoryEvidence.subject;
|
||||||
|
if (target === null) throw new Error("Artifact fixture has no target");
|
||||||
|
|
||||||
|
const inspectionEvidence = (result: unknown, subject = target) =>
|
||||||
|
createEvidence(
|
||||||
|
{
|
||||||
|
path: subject.local_path,
|
||||||
|
sha256: subject.digest.sha256,
|
||||||
|
format: subject.format,
|
||||||
|
},
|
||||||
|
inventoryEvidence.provider,
|
||||||
|
{
|
||||||
|
operation: "inspect_artifact",
|
||||||
|
parameters: {},
|
||||||
|
result: jsonValueSchema.parse(result),
|
||||||
|
confidence: "observed",
|
||||||
|
authority: "shipped-artifact",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const inspection = createArtifactInspection(inventoryEvidence);
|
||||||
|
|
||||||
|
it("accepts an inspection whose carrier, declaration, and substep identities agree", () => {
|
||||||
|
expect(
|
||||||
|
parseArtifactInventoryEvidence(inspectionEvidence(inspection)).inventory,
|
||||||
|
).toMatchObject({ complete: true, coverage: { status: "complete" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an inspection carrier whose Evidence subject disagrees with its nested inventory", () => {
|
||||||
|
const carrier = inspectionEvidence(inspection, {
|
||||||
|
...target,
|
||||||
|
digest: { sha256: "a".repeat(64) },
|
||||||
|
});
|
||||||
|
expect(() => parseArtifactInventoryEvidence(carrier)).toThrow(
|
||||||
|
"Artifact inspection Evidence subject does not match its root manifest",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an inspection declaration whose root manifest disagrees with its nested inventory", () => {
|
||||||
|
const mismatched = {
|
||||||
|
...inspection,
|
||||||
|
subject: {
|
||||||
|
...inspection.subject,
|
||||||
|
root_artifact_id: `art_${"f".repeat(64)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(() =>
|
||||||
|
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
|
||||||
|
).toThrow(
|
||||||
|
"Artifact inspection subject does not match its inventory manifest",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a substep evidence ID that disagrees with its authenticated nested Evidence", () => {
|
||||||
|
const [first, ...remaining] = inspection.substeps;
|
||||||
|
if (first === undefined) throw new Error("Expected inventory substep");
|
||||||
|
const mismatched = {
|
||||||
|
...inspection,
|
||||||
|
substeps: [{ ...first, evidence_id: `ev_${"f".repeat(64)}` }, ...remaining],
|
||||||
|
};
|
||||||
|
expect(() =>
|
||||||
|
parseArtifactInventoryEvidence(inspectionEvidence(mismatched)),
|
||||||
|
).toThrow(
|
||||||
|
"Artifact inspection substep evidence ID does not match nested Evidence",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -17,11 +17,10 @@ import {
|
|||||||
occurrenceIdForLocation,
|
occurrenceIdForLocation,
|
||||||
} from "./artifactIdentity.js";
|
} from "./artifactIdentity.js";
|
||||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||||
import { artifactInspectionResultSchema } from "./artifactInspection.js";
|
|
||||||
import {
|
import {
|
||||||
isZipPackageFormat,
|
artifactInspectionResultSchema,
|
||||||
isZipInventoryScopeLimitation,
|
type ArtifactInspectionResult,
|
||||||
} from "./zipPackageFormat.js";
|
} from "./artifactInspection.js";
|
||||||
|
|
||||||
/** Complete or partially assembled inventory used by comparison workflows. */
|
/** Complete or partially assembled inventory used by comparison workflows. */
|
||||||
export interface InventorySet {
|
export interface InventorySet {
|
||||||
@@ -50,50 +49,136 @@ export interface ParsedInventorySet {
|
|||||||
readonly inventory: InventorySet;
|
readonly inventory: InventorySet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedArtifactInspectionInventory {
|
||||||
|
readonly inspection: ArtifactInspectionResult;
|
||||||
|
readonly inventory: InventorySet;
|
||||||
|
readonly evidenceIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Parse, merge, and verify inventory Evidence pages. */
|
/** Parse, merge, and verify inventory Evidence pages. */
|
||||||
export const parseArtifactInventoryEvidence = (
|
export const parseArtifactInventoryEvidence = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
): ParsedInventorySet => {
|
): ParsedInventorySet => {
|
||||||
const pages = (Array.isArray(input) ? input : [input]).map((value) => {
|
const evidence = (Array.isArray(input) ? input : [input]).map(parseEvidence);
|
||||||
const evidence = parseEvidence(value);
|
const pages = evidence.flatMap((carrier) => {
|
||||||
const inventoryEvidence =
|
if (carrier.operation === "inventory_artifact")
|
||||||
evidence.operation === "inventory_artifact"
|
return [parseInventoryEvidencePage(carrier)];
|
||||||
? evidence
|
if (carrier.operation !== "inspect_artifact")
|
||||||
: evidence.operation === "inspect_artifact"
|
|
||||||
? artifactInspectionResultSchema.parse(evidence.normalized_result)
|
|
||||||
.substeps[0]?.evidence
|
|
||||||
: undefined;
|
|
||||||
if (inventoryEvidence === undefined)
|
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
"Artifact comparison requires inspect_artifact Evidence",
|
"Artifact comparison requires inspect_artifact Evidence",
|
||||||
);
|
);
|
||||||
const inventory = artifactInventoryResultSchema.parse(
|
return parseInspectionInventoryPayload(
|
||||||
inventoryEvidence.normalized_result,
|
carrier.normalized_result,
|
||||||
);
|
carrier.subject,
|
||||||
validateArtifactInventorySubject(
|
).pages;
|
||||||
inventoryEvidence.subject,
|
|
||||||
inventory.manifest,
|
|
||||||
);
|
|
||||||
validateInventoryPage(inventory);
|
|
||||||
return { evidence, inventory };
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
evidence: pages.map(({ evidence }) => evidence),
|
evidence,
|
||||||
inventory: assembleInventoryResults(
|
inventory: assembleInventoryResults(pages),
|
||||||
pages.map(({ inventory }) => inventory),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Parse and authenticate an already-owned inspection payload and its pages. */
|
||||||
|
export const parseArtifactInspectionInventoryResult = (
|
||||||
|
input: unknown,
|
||||||
|
subject: Evidence["subject"],
|
||||||
|
): ParsedArtifactInspectionInventory => {
|
||||||
|
const parsed = parseInspectionInventoryPayload(input, subject);
|
||||||
|
return {
|
||||||
|
inspection: parsed.inspection,
|
||||||
|
inventory: assembleInventoryResults(parsed.pages),
|
||||||
|
evidenceIds: parsed.evidenceIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared owner boundary for full Evidence and already-authenticated parents. */
|
||||||
|
const parseInspectionInventoryPayload = (
|
||||||
|
input: unknown,
|
||||||
|
subject: Evidence["subject"],
|
||||||
|
): {
|
||||||
|
readonly inspection: ArtifactInspectionResult;
|
||||||
|
readonly pages: readonly ArtifactInventoryResult[];
|
||||||
|
readonly evidenceIds: readonly string[];
|
||||||
|
} => {
|
||||||
|
const inspection = artifactInspectionResultSchema.parse(input);
|
||||||
|
if (inspection.coverage.substeps_completed !== inspection.substeps.length)
|
||||||
|
throw new TypeError(
|
||||||
|
"Artifact inspection substep count does not match its coverage declaration",
|
||||||
|
);
|
||||||
|
const substeps = inspection.substeps.map((step) => {
|
||||||
|
const evidence = parseEvidence(step.evidence);
|
||||||
|
if (step.evidence_id !== evidence.evidence_id)
|
||||||
|
throw new TypeError(
|
||||||
|
"Artifact inspection substep evidence ID does not match nested Evidence",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
evidenceId: evidence.evidence_id,
|
||||||
|
page: parseInventoryEvidencePage(evidence),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const firstPage = substeps[0]?.page;
|
||||||
|
if (firstPage === undefined)
|
||||||
|
throw new TypeError("Artifact inspection requires inventory substeps");
|
||||||
|
validateInspectionSubject(subject, inspection, firstPage.manifest);
|
||||||
|
return {
|
||||||
|
inspection,
|
||||||
|
pages: substeps.map(({ page }) => page),
|
||||||
|
evidenceIds: substeps.map(({ evidenceId }) => evidenceId),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ensure the inspection declaration and carrier identify its inventory. */
|
||||||
|
const validateInspectionSubject = (
|
||||||
|
subject: Evidence["subject"],
|
||||||
|
inspection: ArtifactInspectionResult,
|
||||||
|
manifest: ArtifactInventoryResult["manifest"],
|
||||||
|
): void => {
|
||||||
|
if (
|
||||||
|
inspection.subject.manifest_id !== manifest.manifest_id ||
|
||||||
|
inspection.subject.root_artifact_id !== manifest.root_artifact_id ||
|
||||||
|
inspection.subject.root_sha256 !== manifest.root_sha256 ||
|
||||||
|
inspection.subject.root_format !== manifest.root_format
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
"Artifact inspection subject does not match its inventory manifest",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
subject === null ||
|
||||||
|
subject.digest.sha256 !== manifest.root_sha256 ||
|
||||||
|
subject.format !== manifest.root_format
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
"Artifact inspection Evidence subject does not match its root manifest",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Validate one already authenticated parent payload with the same graph proofs. */
|
/** Validate one already authenticated parent payload with the same graph proofs. */
|
||||||
export const parseArtifactInventoryResult = (
|
export const parseArtifactInventoryResult = (
|
||||||
input: unknown,
|
input: unknown,
|
||||||
subject: Evidence["subject"],
|
subject: Evidence["subject"],
|
||||||
): InventorySet => {
|
): InventorySet =>
|
||||||
|
assembleInventoryResults([parseInventoryPage(input, subject)]);
|
||||||
|
|
||||||
|
/** Parse one already authenticated inventory result into its validated facts. */
|
||||||
|
const parseInventoryPage = (
|
||||||
|
input: unknown,
|
||||||
|
subject: Evidence["subject"],
|
||||||
|
): ArtifactInventoryResult => {
|
||||||
const inventory = artifactInventoryResultSchema.parse(input);
|
const inventory = artifactInventoryResultSchema.parse(input);
|
||||||
validateArtifactInventorySubject(subject, inventory.manifest);
|
validateArtifactInventorySubject(subject, inventory.manifest);
|
||||||
validateInventoryPage(inventory);
|
validateInventoryPage(inventory);
|
||||||
return assembleInventoryResults([inventory]);
|
return inventory;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parse one authenticated inventory carrier into its validated owner facts. */
|
||||||
|
const parseInventoryEvidencePage = (
|
||||||
|
evidence: Evidence,
|
||||||
|
): ArtifactInventoryResult => {
|
||||||
|
if (evidence.operation !== "inventory_artifact")
|
||||||
|
throw new TypeError(
|
||||||
|
"Artifact inventory page must be inventory_artifact Evidence",
|
||||||
|
);
|
||||||
|
return parseInventoryPage(evidence.normalized_result, evidence.subject);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Both inventory carriers require the same authenticated root identity. */
|
/** Both inventory carriers require the same authenticated root identity. */
|
||||||
@@ -137,10 +222,7 @@ const assembleInventoryResults = (
|
|||||||
throw new TypeError("Artifact inventory requires Evidence pages");
|
throw new TypeError("Artifact inventory requires Evidence pages");
|
||||||
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
|
const manifestJson = canonicalJson(first.manifest, "Artifact inventory");
|
||||||
const coverageJson = canonicalJson(
|
const coverageJson = canonicalJson(
|
||||||
{
|
first.coverage,
|
||||||
coverage: first.coverage ?? null,
|
|
||||||
coverage_status: first.coverage_status ?? null,
|
|
||||||
},
|
|
||||||
"Artifact inventory coverage",
|
"Artifact inventory coverage",
|
||||||
);
|
);
|
||||||
const nodes = new Map<string, ArtifactNode>();
|
const nodes = new Map<string, ArtifactNode>();
|
||||||
@@ -150,13 +232,8 @@ const assembleInventoryResults = (
|
|||||||
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
|
if (canonicalJson(page.manifest, "Artifact inventory") !== manifestJson)
|
||||||
throw new TypeError("Artifact inventory pages do not share one manifest");
|
throw new TypeError("Artifact inventory pages do not share one manifest");
|
||||||
if (
|
if (
|
||||||
canonicalJson(
|
canonicalJson(page.coverage, "Artifact inventory coverage") !==
|
||||||
{
|
coverageJson
|
||||||
coverage: page.coverage ?? null,
|
|
||||||
coverage_status: page.coverage_status ?? null,
|
|
||||||
},
|
|
||||||
"Artifact inventory coverage",
|
|
||||||
) !== coverageJson
|
|
||||||
)
|
)
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
"Artifact inventory pages do not share one coverage declaration",
|
"Artifact inventory pages do not share one coverage declaration",
|
||||||
@@ -221,7 +298,7 @@ const orderedInventory = (
|
|||||||
edges,
|
edges,
|
||||||
integrityContradictions: first.integrity_contradictions,
|
integrityContradictions: first.integrity_contradictions,
|
||||||
limitations,
|
limitations,
|
||||||
coverage: resolveInventoryCoverage({ ...first, limitations }),
|
coverage: first.coverage,
|
||||||
complete:
|
complete:
|
||||||
nodes.length === first.manifest.node_count &&
|
nodes.length === first.manifest.node_count &&
|
||||||
occurrences.length === first.manifest.occurrence_count &&
|
occurrences.length === first.manifest.occurrence_count &&
|
||||||
@@ -267,61 +344,6 @@ const orderOccurrences = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize both retained coverage formats at this owning boundary.
|
|
||||||
const resolveInventoryCoverage = (
|
|
||||||
inventory: ArtifactInventoryResult,
|
|
||||||
): ArtifactInventoryCoverage => {
|
|
||||||
const legacy = legacyInventoryCoverage(
|
|
||||||
inventory.manifest.root_format,
|
|
||||||
inventory.limitations,
|
|
||||||
inventory.integrity_contradictions.length,
|
|
||||||
);
|
|
||||||
if (inventory.coverage !== undefined) {
|
|
||||||
if (
|
|
||||||
inventory.coverage_status !== undefined &&
|
|
||||||
(inventory.coverage.status === "complete") !==
|
|
||||||
(inventory.coverage_status === "complete")
|
|
||||||
)
|
|
||||||
throw new TypeError(
|
|
||||||
"Artifact inventory has conflicting coverage declarations",
|
|
||||||
);
|
|
||||||
return inventory.coverage;
|
|
||||||
}
|
|
||||||
return inventory.coverage_status === undefined
|
|
||||||
? legacy
|
|
||||||
: {
|
|
||||||
...legacy,
|
|
||||||
status:
|
|
||||||
inventory.coverage_status === "complete" ? "complete" : "unknown",
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Retained pre-coverage records cannot classify arbitrary prose. Recognize only
|
|
||||||
// the historical producer vocabulary here; unfamiliar diagnostics remain unknown.
|
|
||||||
const legacyInventoryCoverage = (
|
|
||||||
format: ArtifactInventoryResult["manifest"]["root_format"],
|
|
||||||
limitations: readonly string[],
|
|
||||||
contradictionCount: number,
|
|
||||||
): ArtifactInventoryCoverage => ({
|
|
||||||
scope:
|
|
||||||
isZipPackageFormat(format) ||
|
|
||||||
["directory", "asar", "dmg", "pkg", "mach-o-universal"].includes(format)
|
|
||||||
? "container-entries"
|
|
||||||
: "root",
|
|
||||||
status: limitations.some(
|
|
||||||
(limitation) =>
|
|
||||||
limitation !== "Artifact has no child container entries." &&
|
|
||||||
!isZipInventoryScopeLimitation(limitation) &&
|
|
||||||
!(
|
|
||||||
contradictionCount > 0 &&
|
|
||||||
limitation ===
|
|
||||||
`${String(contradictionCount)} integrity contradiction(s) were recorded; mismatched content is observed-untrusted.`
|
|
||||||
),
|
|
||||||
)
|
|
||||||
? "unknown"
|
|
||||||
: "complete",
|
|
||||||
});
|
|
||||||
|
|
||||||
const validateCompleteInventory = (inventory: InventorySet): void => {
|
const validateCompleteInventory = (inventory: InventorySet): void => {
|
||||||
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
|
const nodeIds = new Set(inventory.nodes.map(({ artifact_id: id }) => id));
|
||||||
const occurrenceIds = new Set(
|
const occurrenceIds = new Set(
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import type {
|
|||||||
|
|
||||||
/** Immutable inventory produced by one complete artifact scan. */
|
/** Immutable inventory produced by one complete artifact scan. */
|
||||||
export interface ArtifactInventorySnapshot {
|
export interface ArtifactInventorySnapshot {
|
||||||
readonly coverage_status: NonNullable<
|
|
||||||
ArtifactInventoryResult["coverage_status"]
|
|
||||||
>;
|
|
||||||
readonly manifest: ArtifactInventoryResult["manifest"];
|
readonly manifest: ArtifactInventoryResult["manifest"];
|
||||||
readonly nodes: readonly ArtifactNode[];
|
readonly nodes: readonly ArtifactNode[];
|
||||||
readonly occurrences: ArtifactInventoryResult["occurrences"];
|
readonly occurrences: ArtifactInventoryResult["occurrences"];
|
||||||
@@ -18,6 +15,6 @@ export interface ArtifactInventorySnapshot {
|
|||||||
ArtifactInventoryResult["provenance"][number]
|
ArtifactInventoryResult["provenance"][number]
|
||||||
>;
|
>;
|
||||||
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
readonly integrity_contradictions: readonly IntegrityContradiction[];
|
||||||
readonly coverage?: ArtifactInventoryCoverage;
|
readonly coverage: ArtifactInventoryCoverage;
|
||||||
readonly limitations: readonly string[];
|
readonly limitations: readonly string[];
|
||||||
}
|
}
|
||||||
@@ -26,9 +26,7 @@ export const browserNetworkContentSelectionSchema =
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Self-verifying retained bytes, after any declared-secret redaction. */
|
/** Self-verifying retained bytes, after any declared-secret redaction. */
|
||||||
export const browserNetworkBodySchema = z.union([
|
const unavailableBrowserNetworkBodySchema = z.discriminatedUnion("reason", [
|
||||||
z.strictObject({ state: z.literal("not_requested") }),
|
|
||||||
z.strictObject({ state: z.literal("not_exposed") }),
|
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
state: z.literal("unavailable"),
|
state: z.literal("unavailable"),
|
||||||
reason: z.enum([
|
reason: z.enum([
|
||||||
@@ -41,6 +39,26 @@ export const browserNetworkBodySchema = z.union([
|
|||||||
]),
|
]),
|
||||||
message: z.string().min(1),
|
message: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
state: z.literal("unavailable"),
|
||||||
|
reason: z.literal("body-size-limit"),
|
||||||
|
message: z.string().min(1),
|
||||||
|
limit_bytes: z.number().int().positive().safe(),
|
||||||
|
observed_bytes: z.number().int().positive().safe(),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
state: z.literal("unavailable"),
|
||||||
|
reason: z.literal("body-allocation-limit"),
|
||||||
|
message: z.string().min(1),
|
||||||
|
limit_allocation_bytes: z.number().int().positive().safe(),
|
||||||
|
observed_allocation_bytes: z.number().int().positive().safe(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const browserNetworkBodySchema = z.union([
|
||||||
|
z.strictObject({ state: z.literal("not_requested") }),
|
||||||
|
z.strictObject({ state: z.literal("not_exposed") }),
|
||||||
|
unavailableBrowserNetworkBodySchema,
|
||||||
z
|
z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
state: z.literal("captured"),
|
state: z.literal("captured"),
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ const observationFailureMessage = (
|
|||||||
return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`;
|
return `${operation} could not reach the selected loopback debugging endpoint. Verify that the target process is running with debugging enabled and that the endpoint's port matches it, then retry.`;
|
||||||
if (reason === "target_not_found")
|
if (reason === "target_not_found")
|
||||||
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
|
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
|
||||||
|
if (reason === "service_worker_blocked")
|
||||||
|
return `${operation} refused the selected page because it is controlled by or has a registered service worker while the service-worker policy is block. Set environment.service_workers to allow to permit observation, or remove the service worker registration through the browser.`;
|
||||||
if (reason === "disconnected")
|
if (reason === "disconnected")
|
||||||
return `${operation} lost its debugging connection. Verify that the target process is still running, refresh target discovery, and retry the observation.`;
|
return `${operation} lost its debugging connection. Verify that the target process is still running, refresh target discovery, and retry the observation.`;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type BrowserObservationFailureReason =
|
|||||||
| "invalid_endpoint_response"
|
| "invalid_endpoint_response"
|
||||||
| "target_not_found"
|
| "target_not_found"
|
||||||
| "target_not_allowed"
|
| "target_not_allowed"
|
||||||
|
| "service_worker_blocked"
|
||||||
| "target_changed"
|
| "target_changed"
|
||||||
| "protocol_error"
|
| "protocol_error"
|
||||||
| "disconnected"
|
| "disconnected"
|
||||||
|
|||||||
@@ -87,6 +87,34 @@ it("does not require duplicate origin scope declarations", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts caller-selected safe capture limits and rejects invalid bounds", () => {
|
||||||
|
const base = {
|
||||||
|
browser: { mode: "launch", executable_path: "/opt/chromium" },
|
||||||
|
start_url: { url: "https://example.test/" },
|
||||||
|
actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
browserScenarioSchema.parse({
|
||||||
|
...base,
|
||||||
|
capture: {
|
||||||
|
limits: { max_total_event_allocation_bytes: 32 * 1024 * 1024 },
|
||||||
|
},
|
||||||
|
}).capture.limits,
|
||||||
|
).toMatchObject({
|
||||||
|
max_events: 10_000,
|
||||||
|
max_event_allocation_bytes: 4 * 1024 * 1024,
|
||||||
|
max_total_event_allocation_bytes: 32 * 1024 * 1024,
|
||||||
|
max_network_body_bytes: 64 * 1024,
|
||||||
|
});
|
||||||
|
for (const invalid of [0, Number.MAX_SAFE_INTEGER + 1, Number.NaN])
|
||||||
|
expect(
|
||||||
|
browserScenarioSchema.safeParse({
|
||||||
|
...base,
|
||||||
|
capture: { limits: { max_events: invalid } },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts complete browser inputs beyond former string length caps", () => {
|
it("accepts complete browser inputs beyond former string length caps", () => {
|
||||||
const longValue = "value".repeat(20_000);
|
const longValue = "value".repeat(20_000);
|
||||||
const longToken = "a".repeat(70_000);
|
const longToken = "a".repeat(70_000);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
browserStepArtifactsSchema,
|
browserStepArtifactsSchema,
|
||||||
} from "./browserScenarioCaptureValues.js";
|
} from "./browserScenarioCaptureValues.js";
|
||||||
import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js";
|
import { browserScenarioCaptureSchema } from "./browserScenarioCapture.js";
|
||||||
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
|
||||||
|
|
||||||
const networkEvent = {
|
const networkEvent = {
|
||||||
sequence: 1,
|
sequence: 1,
|
||||||
@@ -217,10 +218,21 @@ it("accepts complete scenario output beyond former string caps", () => {
|
|||||||
response_body: false,
|
response_body: false,
|
||||||
header_values: false,
|
header_values: false,
|
||||||
},
|
},
|
||||||
|
event_families: [],
|
||||||
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
},
|
},
|
||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
steps: [step(0), step(1)],
|
steps: [step(0), step(1)],
|
||||||
events: { retained: 0, dropped: 0, items: [] },
|
events: {
|
||||||
|
retained: 0,
|
||||||
|
dropped: 0,
|
||||||
|
items: [],
|
||||||
|
omissions: {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
completeness: {
|
completeness: {
|
||||||
status: "complete",
|
status: "complete",
|
||||||
equality_eligible: true,
|
equality_eligible: true,
|
||||||
@@ -234,6 +246,129 @@ it("accepts complete scenario output beyond former string caps", () => {
|
|||||||
expect(parsed.limitations).toHaveLength(65);
|
expect(parsed.limitations).toHaveLength(65);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates reported event limits and omission accounting", () => {
|
||||||
|
const outputStep = (index: number) => ({
|
||||||
|
step_index: index,
|
||||||
|
step_id: `step-${index}`,
|
||||||
|
action: "wait_for_timeout",
|
||||||
|
elapsed_ms: 0,
|
||||||
|
before_url: sanitizeBrowserUrl("http://127.0.0.1/"),
|
||||||
|
after_url: sanitizeBrowserUrl("http://127.0.0.1/"),
|
||||||
|
event_sequence_start: 1,
|
||||||
|
event_sequence_end: 0,
|
||||||
|
artifacts: {
|
||||||
|
screenshot: { state: "not_requested" },
|
||||||
|
dom: { state: "not_requested" },
|
||||||
|
accessibility: { state: "not_requested" },
|
||||||
|
url: { state: "not_requested" },
|
||||||
|
history: { state: "not_requested" },
|
||||||
|
storage: { state: "not_requested" },
|
||||||
|
},
|
||||||
|
completeness: {
|
||||||
|
status: "complete",
|
||||||
|
equality_eligible: true,
|
||||||
|
missing_sections: [],
|
||||||
|
truncated_sections: [],
|
||||||
|
},
|
||||||
|
status: "completed",
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
const capture = browserScenarioCaptureSchema.safeParse({
|
||||||
|
browser: {
|
||||||
|
mode: "launch",
|
||||||
|
process_ownership: "provider-owned",
|
||||||
|
cleanup: "terminated-owned-process",
|
||||||
|
product: "Chromium",
|
||||||
|
version: "fixture",
|
||||||
|
},
|
||||||
|
scenario: {
|
||||||
|
start_origin: "http://127.0.0.1",
|
||||||
|
action_count: 1,
|
||||||
|
secret_references: [],
|
||||||
|
network_content: {
|
||||||
|
request_body: false,
|
||||||
|
response_body: true,
|
||||||
|
header_values: false,
|
||||||
|
},
|
||||||
|
event_families: ["network"],
|
||||||
|
limits: {
|
||||||
|
...DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
|
max_total_event_allocation_bytes: 32 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
duration_ms: 1,
|
||||||
|
steps: [outputStep(0), outputStep(1)],
|
||||||
|
events: {
|
||||||
|
retained: 0,
|
||||||
|
dropped: 2,
|
||||||
|
items: [],
|
||||||
|
omissions: {
|
||||||
|
by_kind: [
|
||||||
|
{
|
||||||
|
kind: "network-content",
|
||||||
|
count: 1,
|
||||||
|
reasons: ["max-total-event-allocation-bytes"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "request",
|
||||||
|
count: 1,
|
||||||
|
reasons: ["max-network-transactions"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
network_transactions: [
|
||||||
|
{
|
||||||
|
transaction_id: "request-1",
|
||||||
|
omitted_kinds: ["network-content"],
|
||||||
|
reasons: ["max-total-event-allocation-bytes"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
network_transaction_range: {
|
||||||
|
first_transaction_id: "request-2",
|
||||||
|
last_transaction_id: "request-2",
|
||||||
|
count: 1,
|
||||||
|
reason: "max-network-transactions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
completeness: {
|
||||||
|
status: "incomplete",
|
||||||
|
equality_eligible: false,
|
||||||
|
missing_sections: ["events"],
|
||||||
|
truncated_sections: [],
|
||||||
|
},
|
||||||
|
limitations: ["Event coverage is incomplete."],
|
||||||
|
});
|
||||||
|
if (!capture.success) throw new Error("Expected valid omission accounting");
|
||||||
|
expect(capture.data.scenario.limits.max_total_event_allocation_bytes).toBe(
|
||||||
|
32 * 1024 * 1024,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
browserScenarioCaptureSchema.safeParse({
|
||||||
|
...capture.data,
|
||||||
|
events: {
|
||||||
|
...capture.data.events,
|
||||||
|
dropped: 3,
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
for (const limits of [
|
||||||
|
{},
|
||||||
|
{ max_events: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_events },
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
browserScenarioCaptureSchema.safeParse({
|
||||||
|
...capture.data,
|
||||||
|
scenario: { ...capture.data.scenario, limits },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
browserScenarioCaptureSchema.safeParse({
|
||||||
|
...capture.data,
|
||||||
|
scenario: { ...capture.data.scenario, limits: undefined },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("derives equality eligibility from exact completeness state", () => {
|
it("derives equality eligibility from exact completeness state", () => {
|
||||||
expect(
|
expect(
|
||||||
browserScenarioCompletenessSchema.safeParse({
|
browserScenarioCompletenessSchema.safeParse({
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { browserNetworkContentSelectionValuesSchema } from "./browserNetworkEvidence.js";
|
import { browserNetworkContentSelectionValuesSchema } from "./browserNetworkEvidence.js";
|
||||||
|
import { browserScenarioCaptureLimitsValuesSchema } from "./browserScenarioCaptureLimits.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
browserScenarioCompletenessSchema,
|
browserScenarioCompletenessSchema,
|
||||||
browserScenarioEventFamilySchema,
|
browserScenarioEventFamilySchema,
|
||||||
|
browserScenarioEventOmissionsSchema,
|
||||||
browserScenarioEventSchema,
|
browserScenarioEventSchema,
|
||||||
browserScenarioStepSchema,
|
browserScenarioStepSchema,
|
||||||
} from "./browserScenarioCaptureValues.js";
|
} from "./browserScenarioCaptureValues.js";
|
||||||
@@ -23,7 +25,8 @@ export const browserScenarioCaptureSchema = z
|
|||||||
action_count: z.number().int().min(1),
|
action_count: z.number().int().min(1),
|
||||||
secret_references: z.array(z.string().min(1)),
|
secret_references: z.array(z.string().min(1)),
|
||||||
network_content: browserNetworkContentSelectionValuesSchema,
|
network_content: browserNetworkContentSelectionValuesSchema,
|
||||||
event_families: z.array(browserScenarioEventFamilySchema).optional(),
|
event_families: z.array(browserScenarioEventFamilySchema),
|
||||||
|
limits: browserScenarioCaptureLimitsValuesSchema,
|
||||||
}),
|
}),
|
||||||
duration_ms: z.number().int().min(0),
|
duration_ms: z.number().int().min(0),
|
||||||
steps: z.array(browserScenarioStepSchema).min(1),
|
steps: z.array(browserScenarioStepSchema).min(1),
|
||||||
@@ -31,6 +34,7 @@ export const browserScenarioCaptureSchema = z
|
|||||||
retained: z.number().int().min(0),
|
retained: z.number().int().min(0),
|
||||||
dropped: z.number().int().min(0),
|
dropped: z.number().int().min(0),
|
||||||
items: z.array(browserScenarioEventSchema),
|
items: z.array(browserScenarioEventSchema),
|
||||||
|
omissions: browserScenarioEventOmissionsSchema,
|
||||||
}),
|
}),
|
||||||
completeness: browserScenarioCompletenessSchema,
|
completeness: browserScenarioCompletenessSchema,
|
||||||
limitations: z.array(z.string().min(1)),
|
limitations: z.array(z.string().min(1)),
|
||||||
@@ -66,6 +70,17 @@ export const browserScenarioCaptureSchema = z
|
|||||||
path: ["events", "retained"],
|
path: ["events", "retained"],
|
||||||
message: "Retained event count must equal items length",
|
message: "Retained event count must equal items length",
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
capture.events.omissions.by_kind.reduce(
|
||||||
|
(count, omission) => count + omission.count,
|
||||||
|
0,
|
||||||
|
) !== capture.events.dropped
|
||||||
|
)
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["events", "omissions", "by_kind"],
|
||||||
|
message: "Per-kind omission counts must equal the dropped event count",
|
||||||
|
});
|
||||||
if (capture.steps.length !== capture.scenario.action_count + 1)
|
if (capture.steps.length !== capture.scenario.action_count + 1)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const positiveSafeInteger = z.number().int().safe().positive();
|
||||||
|
|
||||||
|
export const DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS = {
|
||||||
|
max_events: 10_000,
|
||||||
|
max_event_allocation_bytes: 4 * 1024 * 1024,
|
||||||
|
max_total_event_allocation_bytes: 16 * 1024 * 1024,
|
||||||
|
max_network_transactions: 1024,
|
||||||
|
max_network_body_bytes: 64 * 1024,
|
||||||
|
max_network_body_allocation_bytes: 1024 * 1024,
|
||||||
|
max_network_header_allocation_bytes: 1024 * 1024,
|
||||||
|
max_network_metadata_allocation_bytes: 1024 * 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Caller-selected retained-output budgets; byte values are allocation-cost estimates. */
|
||||||
|
export const browserScenarioCaptureLimitsValuesSchema = z.strictObject({
|
||||||
|
max_events: positiveSafeInteger,
|
||||||
|
max_event_allocation_bytes: positiveSafeInteger,
|
||||||
|
max_total_event_allocation_bytes: positiveSafeInteger,
|
||||||
|
max_network_transactions: positiveSafeInteger,
|
||||||
|
max_network_body_bytes: positiveSafeInteger,
|
||||||
|
max_network_body_allocation_bytes: positiveSafeInteger,
|
||||||
|
max_network_header_allocation_bytes: positiveSafeInteger,
|
||||||
|
max_network_metadata_allocation_bytes: positiveSafeInteger,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Input budgets default omitted values; output evidence uses the values schema above. */
|
||||||
|
export const browserScenarioCaptureLimitsSchema =
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.extend({
|
||||||
|
max_events:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_events.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_events,
|
||||||
|
),
|
||||||
|
max_event_allocation_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_event_allocation_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_event_allocation_bytes,
|
||||||
|
),
|
||||||
|
max_total_event_allocation_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_total_event_allocation_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_total_event_allocation_bytes,
|
||||||
|
),
|
||||||
|
max_network_transactions:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_network_transactions.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_transactions,
|
||||||
|
),
|
||||||
|
max_network_body_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_network_body_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_bytes,
|
||||||
|
),
|
||||||
|
max_network_body_allocation_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_network_body_allocation_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_body_allocation_bytes,
|
||||||
|
),
|
||||||
|
max_network_header_allocation_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_network_header_allocation_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_header_allocation_bytes,
|
||||||
|
),
|
||||||
|
max_network_metadata_allocation_bytes:
|
||||||
|
browserScenarioCaptureLimitsValuesSchema.shape.max_network_metadata_allocation_bytes.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS.max_network_metadata_allocation_bytes,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BrowserScenarioCaptureLimits = z.infer<
|
||||||
|
typeof browserScenarioCaptureLimitsValuesSchema
|
||||||
|
>;
|
||||||
@@ -279,6 +279,74 @@ const completenessSectionSchema = z.enum([
|
|||||||
"popups",
|
"popups",
|
||||||
"websockets",
|
"websockets",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const browserScenarioEventKindSchema = z.enum([
|
||||||
|
"console",
|
||||||
|
"page-error",
|
||||||
|
"request",
|
||||||
|
"response",
|
||||||
|
"request-failed",
|
||||||
|
"request-finished",
|
||||||
|
"request-unfinished",
|
||||||
|
"network-content",
|
||||||
|
"websocket-opened",
|
||||||
|
"websocket-closed",
|
||||||
|
"websocket-frame-sent",
|
||||||
|
"websocket-frame-received",
|
||||||
|
"frame-attached",
|
||||||
|
"frame-detached",
|
||||||
|
"frame-navigated",
|
||||||
|
"worker-created",
|
||||||
|
"worker-closed",
|
||||||
|
"popup-opened",
|
||||||
|
"popup-closed",
|
||||||
|
"download-cancelled",
|
||||||
|
]);
|
||||||
|
export const browserScenarioEventBudgetReasonSchema = z.enum([
|
||||||
|
"max-events",
|
||||||
|
"max-event-allocation-bytes",
|
||||||
|
"max-total-event-allocation-bytes",
|
||||||
|
"max-network-transactions",
|
||||||
|
"max-network-metadata-allocation-bytes",
|
||||||
|
"max-listener-owners",
|
||||||
|
]);
|
||||||
|
export const browserScenarioEventOmissionsSchema = z.strictObject({
|
||||||
|
by_kind: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
kind: browserScenarioEventKindSchema,
|
||||||
|
count: z.number().int().positive(),
|
||||||
|
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
network_transactions: z.array(
|
||||||
|
z.strictObject({
|
||||||
|
transaction_id: z.string().min(1),
|
||||||
|
omitted_kinds: z.array(browserScenarioEventKindSchema).min(1),
|
||||||
|
reasons: z.array(browserScenarioEventBudgetReasonSchema).min(1),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
network_transaction_range: z
|
||||||
|
.strictObject({
|
||||||
|
first_transaction_id: z.string().min(1),
|
||||||
|
last_transaction_id: z.string().min(1),
|
||||||
|
count: z.number().int().positive(),
|
||||||
|
reason: z.literal("max-network-transactions"),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
export type BrowserScenarioEventOmissions = z.infer<
|
||||||
|
typeof browserScenarioEventOmissionsSchema
|
||||||
|
>;
|
||||||
|
export type BrowserScenarioEventBudgetReason = z.infer<
|
||||||
|
typeof browserScenarioEventBudgetReasonSchema
|
||||||
|
>;
|
||||||
|
export type BrowserScenarioEventAdmission =
|
||||||
|
| { readonly status: "retained"; readonly sequence: number }
|
||||||
|
| {
|
||||||
|
readonly status: "omitted";
|
||||||
|
readonly sequence: number;
|
||||||
|
readonly reason: BrowserScenarioEventBudgetReason;
|
||||||
|
};
|
||||||
export type BrowserScenarioCompletenessSection = z.infer<
|
export type BrowserScenarioCompletenessSection = z.infer<
|
||||||
typeof completenessSectionSchema
|
typeof completenessSectionSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import {
|
|||||||
browserScenarioCaptureSchema,
|
browserScenarioCaptureSchema,
|
||||||
type BrowserScenarioCapture,
|
type BrowserScenarioCapture,
|
||||||
} from "./browserScenarioCapture.js";
|
} from "./browserScenarioCapture.js";
|
||||||
|
import { DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS } from "./browserScenarioCaptureLimits.js";
|
||||||
|
|
||||||
|
const noEventOmissions = {
|
||||||
|
by_kind: [],
|
||||||
|
network_transactions: [],
|
||||||
|
network_transaction_range: null,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const networkCapture = (sourceSequence: number, receiptStep: number) => {
|
const networkCapture = (sourceSequence: number, receiptStep: number) => {
|
||||||
const base = scenarioCapture({});
|
const base = scenarioCapture({});
|
||||||
@@ -65,7 +72,12 @@ const networkCapture = (sourceSequence: number, receiptStep: number) => {
|
|||||||
},
|
},
|
||||||
event_families: ["network"],
|
event_families: ["network"],
|
||||||
},
|
},
|
||||||
events: { items: events, retained: 2, dropped: 0 },
|
events: {
|
||||||
|
items: events,
|
||||||
|
retained: 2,
|
||||||
|
dropped: 0,
|
||||||
|
omissions: noEventOmissions,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,7 +160,7 @@ it("reports content-selection mismatch as unknown instead of a website change",
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports event-family mismatch and legacy selection as unknown", () => {
|
it("reports event-family selection mismatch as unknown", () => {
|
||||||
const consoleOnly = scenarioCapture({});
|
const consoleOnly = scenarioCapture({});
|
||||||
const withFamilies = (
|
const withFamilies = (
|
||||||
capture: BrowserScenarioCapture,
|
capture: BrowserScenarioCapture,
|
||||||
@@ -193,18 +205,6 @@ it("reports event-family mismatch and legacy selection as unknown", () => {
|
|||||||
).overall_status,
|
).overall_status,
|
||||||
).toBe("unchanged");
|
).toBe("unchanged");
|
||||||
|
|
||||||
const legacy = withFamilies(consoleOnly, undefined);
|
|
||||||
expect(legacy.scenario.event_families).toBeUndefined();
|
|
||||||
for (const [before, after] of [
|
|
||||||
[legacy, consoleOnly],
|
|
||||||
[legacy, legacy],
|
|
||||||
] as const)
|
|
||||||
expect(eventDiffs(compare(before, after))).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
status: "unknown",
|
|
||||||
reason: expect.stringContaining("Event family selection is unknown"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
compareBrowserScenariosInputSchema.safeParse({
|
compareBrowserScenariosInputSchema.safeParse({
|
||||||
before_scenario: {
|
before_scenario: {
|
||||||
@@ -217,7 +217,7 @@ it("reports event-family mismatch and legacy selection as unknown", () => {
|
|||||||
|
|
||||||
const quiet = browserScenarioCaptureSchema.parse({
|
const quiet = browserScenarioCaptureSchema.parse({
|
||||||
...withFamilies(consoleOnly, []),
|
...withFamilies(consoleOnly, []),
|
||||||
events: { retained: 0, dropped: 0, items: [] },
|
events: { retained: 0, dropped: 0, items: [], omissions: noEventOmissions },
|
||||||
});
|
});
|
||||||
const differentEvents = compare(quiet, widened);
|
const differentEvents = compare(quiet, widened);
|
||||||
expect(differentEvents.overall_status).toBe("unknown");
|
expect(differentEvents.overall_status).toBe("unknown");
|
||||||
@@ -283,7 +283,12 @@ const concurrentCapture = (
|
|||||||
).map((event, index) => ({ ...event, sequence: index + 1 }));
|
).map((event, index) => ({ ...event, sequence: index + 1 }));
|
||||||
return browserScenarioCaptureSchema.parse({
|
return browserScenarioCaptureSchema.parse({
|
||||||
...base,
|
...base,
|
||||||
events: { items: events, retained: events.length, dropped: 0 },
|
events: {
|
||||||
|
items: events,
|
||||||
|
retained: events.length,
|
||||||
|
dropped: 0,
|
||||||
|
omissions: noEventOmissions,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,7 +343,12 @@ const redirectChainCapture = (swapLeaves = false, reverseReceipt = false) => {
|
|||||||
.map((event, index) => ({ ...event, sequence: index + 1 }));
|
.map((event, index) => ({ ...event, sequence: index + 1 }));
|
||||||
return browserScenarioCaptureSchema.parse({
|
return browserScenarioCaptureSchema.parse({
|
||||||
...base,
|
...base,
|
||||||
events: { items: events, retained: events.length, dropped: 0 },
|
events: {
|
||||||
|
items: events,
|
||||||
|
retained: events.length,
|
||||||
|
dropped: 0,
|
||||||
|
omissions: noEventOmissions,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -465,6 +475,7 @@ describe("browser scenario network comparison stability", () => {
|
|||||||
events: {
|
events: {
|
||||||
retained: 2,
|
retained: 2,
|
||||||
dropped: 0,
|
dropped: 0,
|
||||||
|
omissions: noEventOmissions,
|
||||||
items: [
|
items: [
|
||||||
{ ...consoleEvent, text },
|
{ ...consoleEvent, text },
|
||||||
{
|
{
|
||||||
@@ -938,6 +949,7 @@ const scenarioCapture = (
|
|||||||
header_values: false,
|
header_values: false,
|
||||||
},
|
},
|
||||||
event_families: ["console"],
|
event_families: ["console"],
|
||||||
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
},
|
},
|
||||||
duration_ms: options.elapsedMs ?? 10,
|
duration_ms: options.elapsedMs ?? 10,
|
||||||
steps: [
|
steps: [
|
||||||
@@ -983,6 +995,7 @@ const scenarioCapture = (
|
|||||||
url: null,
|
url: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
omissions: noEventOmissions,
|
||||||
},
|
},
|
||||||
completeness,
|
completeness,
|
||||||
limitations: [],
|
limitations: [],
|
||||||
|
|||||||
@@ -158,15 +158,13 @@ export const networkContentCoverageReason = (
|
|||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Different or unrecorded event-family selection is missing comparability, not an observed website change. */
|
/** Different event-family selection is missing comparability, not an observed website change. */
|
||||||
export const eventFamilyCoverageReason = (
|
export const eventFamilyCoverageReason = (
|
||||||
before: BrowserScenarioCapture,
|
before: BrowserScenarioCapture,
|
||||||
after: BrowserScenarioCapture,
|
after: BrowserScenarioCapture,
|
||||||
): string | undefined => {
|
): string | undefined => {
|
||||||
const left = before.scenario.event_families;
|
const left = before.scenario.event_families;
|
||||||
const right = after.scenario.event_families;
|
const right = after.scenario.event_families;
|
||||||
if (left === undefined || right === undefined)
|
|
||||||
return "Event family selection is unknown in a legacy capture; capture both sides with matching event families before comparing event evidence.";
|
|
||||||
const leftFamilies = new Set(left);
|
const leftFamilies = new Set(left);
|
||||||
const rightFamilies = new Set(right);
|
const rightFamilies = new Set(right);
|
||||||
return leftFamilies.size === rightFamilies.size &&
|
return leftFamilies.size === rightFamilies.size &&
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { IDENTIFIER_PATTERN } from "./stringPatterns.js";
|
|||||||
import { localPathStringSchema } from "./localPath.js";
|
import { localPathStringSchema } from "./localPath.js";
|
||||||
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
|
import { browserNetworkContentSelectionSchema } from "./browserNetworkEvidence.js";
|
||||||
import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js";
|
import { browserScenarioEventFamilySchema } from "./browserScenarioCaptureValues.js";
|
||||||
|
import {
|
||||||
|
browserScenarioCaptureLimitsSchema,
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
|
} from "./browserScenarioCaptureLimits.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
browserEndpointSchema,
|
browserEndpointSchema,
|
||||||
@@ -292,6 +296,9 @@ export const browserScenarioCaptureSchema = z
|
|||||||
at_end: z.array(snapshotKindSchema).default(["url"]),
|
at_end: z.array(snapshotKindSchema).default(["url"]),
|
||||||
events: z.array(browserScenarioEventFamilySchema).default([]),
|
events: z.array(browserScenarioEventFamilySchema).default([]),
|
||||||
network: browserNetworkContentSelectionSchema,
|
network: browserNetworkContentSelectionSchema,
|
||||||
|
limits: browserScenarioCaptureLimitsSchema.default(
|
||||||
|
DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
after_each_step: [],
|
after_each_step: [],
|
||||||
@@ -302,6 +309,7 @@ export const browserScenarioCaptureSchema = z
|
|||||||
response_body: false,
|
response_body: false,
|
||||||
header_values: false,
|
header_values: false,
|
||||||
},
|
},
|
||||||
|
limits: DEFAULT_BROWSER_SCENARIO_CAPTURE_LIMITS,
|
||||||
})
|
})
|
||||||
.describe(
|
.describe(
|
||||||
"Optional retained artifacts and event families. Defaults to only a final sanitized URL; request screenshots, DOM, accessibility, history, storage, and event capture explicitly.",
|
"Optional retained artifacts and event families. Defaults to only a final sanitized URL; request screenshots, DOM, accessibility, history, storage, and event capture explicitly.",
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ it("preserves omitted relationship diagnostics and authenticates reported diagno
|
|||||||
).toThrow("semantic identifier");
|
).toThrow("semantic identifier");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires explicit coverage in serialized Evidence while producers encode omission as unknown", () => {
|
||||||
|
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||||
|
operation: "procedure_callees",
|
||||||
|
parameters: { procedure: "0x1000" },
|
||||||
|
result: ["0x2000"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(evidence.relationship_coverage).toBeNull();
|
||||||
|
expect(parseEvidence(JSON.parse(JSON.stringify(evidence)))).toEqual(evidence);
|
||||||
|
const oldEnvelope = { ...evidence } as Record<string, unknown>;
|
||||||
|
delete oldEnvelope.relationship_coverage;
|
||||||
|
expect(evidenceSchema.safeParse(oldEnvelope).success).toBe(false);
|
||||||
|
expect(() => parseEvidence(oldEnvelope)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("projects known provider fields and preserves open JSON payloads", () => {
|
it("projects known provider fields and preserves open JSON payloads", () => {
|
||||||
const liveProvider = { ...PROVIDER, transient_status: "starting" };
|
const liveProvider = { ...PROVIDER, transient_status: "starting" };
|
||||||
const evidence = createEvidence(TARGET, liveProvider, {
|
const evidence = createEvidence(TARGET, liveProvider, {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const evidenceBaseSchema = z
|
|||||||
authority: evidenceAuthoritySchema,
|
authority: evidenceAuthoritySchema,
|
||||||
environment: executionEnvironmentSchema.nullable(),
|
environment: executionEnvironmentSchema.nullable(),
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
relationship_coverage: relationshipCoverageSchema.nullable().default(null),
|
relationship_coverage: relationshipCoverageSchema.nullable(),
|
||||||
locations: z.array(evidenceLocationSchema),
|
locations: z.array(evidenceLocationSchema),
|
||||||
evidence_links: z.array(prefixedDigestSchema("ev")),
|
evidence_links: z.array(prefixedDigestSchema("ev")),
|
||||||
})
|
})
|
||||||
@@ -264,9 +264,7 @@ const semanticProjection = (evidence: EvidenceWithoutId): JsonValue => ({
|
|||||||
authority: evidence.authority,
|
authority: evidence.authority,
|
||||||
environment: evidence.environment,
|
environment: evidence.environment,
|
||||||
limitations: evidence.limitations,
|
limitations: evidence.limitations,
|
||||||
...(evidence.relationship_coverage === null
|
relationship_coverage: evidence.relationship_coverage,
|
||||||
? {}
|
|
||||||
: { relationship_coverage: evidence.relationship_coverage }),
|
|
||||||
locations: evidence.locations,
|
locations: evidence.locations,
|
||||||
evidence_links: evidence.evidence_links,
|
evidence_links: evidence.evidence_links,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
assessFunctionCallCoverage,
|
||||||
combineFunctionRelationshipCoverage,
|
combineFunctionRelationshipCoverage,
|
||||||
isCallerOnlyIncompleteCoverage,
|
isCallerOnlyIncompleteCoverage,
|
||||||
} from "./functionCallCoverage.js";
|
} from "./functionCallCoverage.js";
|
||||||
@@ -88,3 +89,26 @@ describe("isCallerOnlyIncompleteCoverage", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("assessFunctionCallCoverage", () => {
|
||||||
|
it("keeps incomplete coverage and unresolved calls as equality blockers", () => {
|
||||||
|
expect(
|
||||||
|
assessFunctionCallCoverage(
|
||||||
|
{
|
||||||
|
status: "partial",
|
||||||
|
limitations: ["Read limit reached."],
|
||||||
|
diagnostics: ["Indirect calls are unresolved."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
unresolved_calls: [{ address: "0x1004", reason: "Computed target." }],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
blockers: [
|
||||||
|
"Read limit reached.",
|
||||||
|
"Unresolved call at 0x1004: Computed target.",
|
||||||
|
],
|
||||||
|
diagnostics: ["Indirect calls are unresolved."],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,16 +78,23 @@ export const isCallerOnlyIncompleteCoverage = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Explain why two resolved call sets cannot establish call-set equality. */
|
export interface FunctionCallCoverageAssessment {
|
||||||
export const functionCallCoverageLimitations = (
|
/** Reasons the observed call sets cannot establish equality. */
|
||||||
|
readonly blockers: readonly string[];
|
||||||
|
/** Provider caveats that remain relevant even with complete coverage. */
|
||||||
|
readonly diagnostics: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep observed-set blockers separate from descriptive provider caveats. */
|
||||||
|
export const assessFunctionCallCoverage = (
|
||||||
coverage: RelationshipCoverage | null,
|
coverage: RelationshipCoverage | null,
|
||||||
dossier: Pick<FunctionDossier, "unresolved_calls">,
|
dossier: Pick<FunctionDossier, "unresolved_calls">,
|
||||||
): string[] => {
|
): FunctionCallCoverageAssessment => ({
|
||||||
return uniqueSorted([
|
blockers: uniqueSorted([
|
||||||
...relationshipCoverageLimitations(coverage),
|
...relationshipCoverageLimitations(coverage),
|
||||||
...(coverage?.status === "complete" ? [] : (coverage?.diagnostics ?? [])),
|
|
||||||
...(dossier.unresolved_calls ?? []).map(
|
...(dossier.unresolved_calls ?? []).map(
|
||||||
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
|
({ address, reason }) => `Unresolved call at ${address}: ${reason}`,
|
||||||
),
|
),
|
||||||
]);
|
]),
|
||||||
};
|
diagnostics: uniqueSorted(coverage?.diagnostics ?? []),
|
||||||
|
});
|
||||||
@@ -291,25 +291,51 @@ describe("function comparison normalized identity", () => {
|
|||||||
).toMatchObject({ status: "unknown" });
|
).toMatchObject({ status: "unknown" });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("function comparison address-derived call names", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ name: "sub_deallocate", status: "unchanged" },
|
{ name: "sub_deallocate", status: "unchanged", diagnostic: undefined },
|
||||||
{ name: "sub_deadbeef", status: "unknown" },
|
{
|
||||||
{ name: "FUN_00401000", status: "unknown" },
|
name: "sub_deadbeef",
|
||||||
{ name: "FUN_initialize", status: "unchanged" },
|
status: "unknown",
|
||||||
])("preserves the calls dimension for callee $name", ({ name, status }) => {
|
diagnostic: "Provider retained an unresolved call target.",
|
||||||
const calling = (base: "0x1000" | "0x2000") =>
|
},
|
||||||
functionDossierSchema.parse({
|
{ name: "FUN_00401000", status: "unknown", diagnostic: undefined },
|
||||||
...dossier("return helper();", base),
|
{ name: "FUN_initialize", status: "unchanged", diagnostic: undefined },
|
||||||
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
|
])(
|
||||||
});
|
"preserves the calls dimension for callee $name",
|
||||||
const result = compareFunctions(
|
({ name, status, diagnostic }) => {
|
||||||
observe("6", calling("0x1000")),
|
const calling = (base: "0x1000" | "0x2000") =>
|
||||||
observe("7", calling("0x2000")),
|
functionDossierSchema.parse({
|
||||||
);
|
...dossier("return helper();", base),
|
||||||
expect(
|
callees: [procedure(base === "0x1000" ? "0x1010" : "0x2010", name)],
|
||||||
result.dimensions.find(({ dimension }) => dimension === "calls"),
|
});
|
||||||
).toMatchObject({ status });
|
const result = compareFunctions(
|
||||||
});
|
observe("6", calling("0x1000")),
|
||||||
|
observe(
|
||||||
|
"7",
|
||||||
|
calling("0x2000"),
|
||||||
|
"rea-workflow",
|
||||||
|
diagnostic === undefined
|
||||||
|
? { status: "complete" }
|
||||||
|
: { status: "complete", diagnostics: [diagnostic] },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const calls = result.dimensions.find(
|
||||||
|
({ dimension }) => dimension === "calls",
|
||||||
|
);
|
||||||
|
expect(calls).toMatchObject({ status });
|
||||||
|
if (diagnostic !== undefined)
|
||||||
|
expect(calls).toMatchObject({
|
||||||
|
status: "unknown",
|
||||||
|
limitations: expect.arrayContaining([
|
||||||
|
"Address-derived callee or caller names cannot be matched safely.",
|
||||||
|
`Right: ${diagnostic}`,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("function call relationship coverage", () => {
|
describe("function call relationship coverage", () => {
|
||||||
@@ -681,6 +707,52 @@ describe("function comparison unresolved text", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("function comparison", () => {
|
describe("function comparison", () => {
|
||||||
|
it("retains complete call coverage diagnostics without blocking observed-set equality", () => {
|
||||||
|
const diagnostic = "Indirect calls remain unresolved.";
|
||||||
|
const left = observe("a", dossier("return 0;", "0x1000"), "rea-workflow", {
|
||||||
|
status: "complete",
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
});
|
||||||
|
const right = observe("b", dossier("return 0;", "0x2000"), "rea-workflow", {
|
||||||
|
status: "complete",
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
});
|
||||||
|
const comparison = compareFunctions(left, right);
|
||||||
|
expect(
|
||||||
|
comparison.dimensions.find(({ dimension }) => dimension === "calls"),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "unchanged",
|
||||||
|
limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains diagnostics alongside incomplete call coverage blockers", () => {
|
||||||
|
const diagnostic = "Indirect calls remain unresolved.";
|
||||||
|
const left = observe("c", dossier("return 0;", "0x1000"), "rea-workflow", {
|
||||||
|
status: "partial",
|
||||||
|
limitations: ["Provider read limit reached."],
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
});
|
||||||
|
const right = observe("d", dossier("return 0;", "0x2000"), "rea-workflow", {
|
||||||
|
status: "partial",
|
||||||
|
limitations: ["Provider read limit reached."],
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
compareFunctions(left, right).dimensions.find(
|
||||||
|
({ dimension }) => dimension === "calls",
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "unknown",
|
||||||
|
limitations: expect.arrayContaining([
|
||||||
|
"Left: Provider read limit reached.",
|
||||||
|
`Left: ${diagnostic}`,
|
||||||
|
"Right: Provider read limit reached.",
|
||||||
|
`Right: ${diagnostic}`,
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves text changes alongside complete inline dossier facets", () => {
|
it("preserves text changes alongside complete inline dossier facets", () => {
|
||||||
const result = compareFunctions(
|
const result = compareFunctions(
|
||||||
FUNCTION_COMPARISON_EXAMPLE.left,
|
FUNCTION_COMPARISON_EXAMPLE.left,
|
||||||
|
|||||||
@@ -333,8 +333,12 @@ const compareCalls = (
|
|||||||
const leftValues = callProjection(left);
|
const leftValues = callProjection(left);
|
||||||
const rightValues = callProjection(right);
|
const rightValues = callProjection(right);
|
||||||
const coverageLimitations = [
|
const coverageLimitations = [
|
||||||
...left.callCoverageLimitations.map((reason) => `Left: ${reason}`),
|
...left.callCoverageBlockers.map((reason) => `Left: ${reason}`),
|
||||||
...right.callCoverageLimitations.map((reason) => `Right: ${reason}`),
|
...right.callCoverageBlockers.map((reason) => `Right: ${reason}`),
|
||||||
|
];
|
||||||
|
const coverageDiagnostics = [
|
||||||
|
...left.callCoverageDiagnostics.map((reason) => `Left: ${reason}`),
|
||||||
|
...right.callCoverageDiagnostics.map((reason) => `Right: ${reason}`),
|
||||||
];
|
];
|
||||||
if (coverageLimitations.length > 0)
|
if (coverageLimitations.length > 0)
|
||||||
return unresolvedDimension({
|
return unresolvedDimension({
|
||||||
@@ -342,7 +346,7 @@ const compareCalls = (
|
|||||||
links: context.links,
|
links: context.links,
|
||||||
leftCount: leftValues.length,
|
leftCount: leftValues.length,
|
||||||
rightCount: rightValues.length,
|
rightCount: rightValues.length,
|
||||||
limitations: coverageLimitations,
|
limitations: [...coverageLimitations, ...coverageDiagnostics],
|
||||||
});
|
});
|
||||||
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
|
if ([...leftValues, ...rightValues].some(({ name }) => isAutoName(name)))
|
||||||
return unresolvedDimension({
|
return unresolvedDimension({
|
||||||
@@ -352,6 +356,7 @@ const compareCalls = (
|
|||||||
rightCount: rightValues.length,
|
rightCount: rightValues.length,
|
||||||
limitations: [
|
limitations: [
|
||||||
"Address-derived callee or caller names cannot be matched safely.",
|
"Address-derived callee or caller names cannot be matched safely.",
|
||||||
|
...coverageDiagnostics,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return compareValues(
|
return compareValues(
|
||||||
@@ -359,6 +364,7 @@ const compareCalls = (
|
|||||||
sorted(leftValues),
|
sorted(leftValues),
|
||||||
sorted(rightValues),
|
sorted(rightValues),
|
||||||
context,
|
context,
|
||||||
|
coverageDiagnostics,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,6 +406,7 @@ const compareValues = (
|
|||||||
left: unknown,
|
left: unknown,
|
||||||
right: unknown,
|
right: unknown,
|
||||||
context: ComparisonContext,
|
context: ComparisonContext,
|
||||||
|
limitations: readonly string[] = [],
|
||||||
): FunctionDimension => {
|
): FunctionDimension => {
|
||||||
const leftJson = canonicalJson(left, "Function comparison");
|
const leftJson = canonicalJson(left, "Function comparison");
|
||||||
const rightJson = canonicalJson(right, "Function comparison");
|
const rightJson = canonicalJson(right, "Function comparison");
|
||||||
@@ -412,6 +419,6 @@ const compareValues = (
|
|||||||
leftCount: Array.isArray(left) ? left.length : null,
|
leftCount: Array.isArray(left) ? left.length : null,
|
||||||
rightCount: Array.isArray(right) ? right.length : null,
|
rightCount: Array.isArray(right) ? right.length : null,
|
||||||
textDelta: null,
|
textDelta: null,
|
||||||
limitations: [],
|
limitations,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { parseEvidence, type Evidence } from "./evidence.js";
|
import { parseEvidence, type Evidence } from "./evidence.js";
|
||||||
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
import { compareUnicodeCodePoints } from "./unicodeCodePointOrder.js";
|
||||||
import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js";
|
import { functionDossierSchema, type FunctionDossier } from "./hopperValues.js";
|
||||||
import { functionCallCoverageLimitations } from "./functionCallCoverage.js";
|
import { assessFunctionCallCoverage } from "./functionCallCoverage.js";
|
||||||
|
|
||||||
/** Complete function observation parsed from one analyze_function Evidence record. */
|
/** Complete function observation parsed from one analyze_function Evidence record. */
|
||||||
export interface FunctionSnapshot {
|
export interface FunctionSnapshot {
|
||||||
@@ -9,7 +9,8 @@ export interface FunctionSnapshot {
|
|||||||
readonly subject: NonNullable<Evidence["subject"]>;
|
readonly subject: NonNullable<Evidence["subject"]>;
|
||||||
readonly dossier: FunctionDossier;
|
readonly dossier: FunctionDossier;
|
||||||
readonly limitations: readonly string[];
|
readonly limitations: readonly string[];
|
||||||
readonly callCoverageLimitations: readonly string[];
|
readonly callCoverageBlockers: readonly string[];
|
||||||
|
readonly callCoverageDiagnostics: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse one complete analyze_function Evidence record for comparison workflows. */
|
/** Parse one complete analyze_function Evidence record for comparison workflows. */
|
||||||
@@ -22,7 +23,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => {
|
|||||||
if (evidence.subject === null)
|
if (evidence.subject === null)
|
||||||
throw new TypeError("Function comparison requires artifact-bound Evidence");
|
throw new TypeError("Function comparison requires artifact-bound Evidence");
|
||||||
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
const dossier = functionDossierSchema.parse(evidence.normalized_result);
|
||||||
const callCoverageLimitations = functionCallCoverageLimitations(
|
const callCoverage = assessFunctionCallCoverage(
|
||||||
evidence.relationship_coverage,
|
evidence.relationship_coverage,
|
||||||
dossier,
|
dossier,
|
||||||
);
|
);
|
||||||
@@ -33,6 +34,7 @@ export const parseFunctionEvidence = (input: unknown): FunctionSnapshot => {
|
|||||||
limitations: [
|
limitations: [
|
||||||
...new Set([...evidence.limitations, ...dossier.limitations]),
|
...new Set([...evidence.limitations, ...dossier.limitations]),
|
||||||
].sort((left, right) => compareUnicodeCodePoints(left, right)),
|
].sort((left, right) => compareUnicodeCodePoints(left, right)),
|
||||||
callCoverageLimitations,
|
callCoverageBlockers: callCoverage.blockers,
|
||||||
|
callCoverageDiagnostics: callCoverage.diagnostics,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -30,6 +30,58 @@ const HASH = {
|
|||||||
const EVIDENCE_ID = `ev_${"a".repeat(64)}`;
|
const EVIDENCE_ID = `ev_${"a".repeat(64)}`;
|
||||||
|
|
||||||
describe("historical source to bundle comparison", () => {
|
describe("historical source to bundle comparison", () => {
|
||||||
|
it("matches an unchanged JSON source artifact by its exact content digest", () => {
|
||||||
|
const path = "src/data.json";
|
||||||
|
const artifact = createJavaScriptApplicationNode({
|
||||||
|
kind: "artifact",
|
||||||
|
identity: {
|
||||||
|
strategy: "content-digest",
|
||||||
|
stability: "global-exact",
|
||||||
|
sha256: HASH.unchanged,
|
||||||
|
},
|
||||||
|
observations: [
|
||||||
|
{
|
||||||
|
label: path,
|
||||||
|
properties: { path, file_kind: "json" },
|
||||||
|
evidence: artifactEvidence(HASH.unchanged, path),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = compareSourceToBundle({
|
||||||
|
reference: historicalGraph("complete", [path]),
|
||||||
|
application: {
|
||||||
|
evidenceId: EVIDENCE_ID,
|
||||||
|
rootArtifactSha256: HASH.artifact,
|
||||||
|
graph: createJavaScriptApplicationGraph({
|
||||||
|
schema: "JavaScriptApplicationGraph",
|
||||||
|
root_node_ids: [artifact.node_id],
|
||||||
|
nodes: [artifact],
|
||||||
|
edges: [],
|
||||||
|
coverage: {
|
||||||
|
status: "complete",
|
||||||
|
truncated: false,
|
||||||
|
omitted_count: 0,
|
||||||
|
limits: [],
|
||||||
|
},
|
||||||
|
limitations: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item(result, path)).toMatchObject({
|
||||||
|
status: "unchanged",
|
||||||
|
confidence: "exact",
|
||||||
|
current_node_ids: [artifact.node_id],
|
||||||
|
candidates: [
|
||||||
|
expect.objectContaining({
|
||||||
|
signals: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "exact-source-digest" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies exact, changed, absent, split, and merged mappings stably", () => {
|
it("classifies exact, changed, absent, split, and merged mappings stably", () => {
|
||||||
const reference = historicalGraph("complete");
|
const reference = historicalGraph("complete");
|
||||||
const graph = applicationGraph("complete");
|
const graph = applicationGraph("complete");
|
||||||
@@ -120,7 +172,9 @@ describe("historical source to bundle comparison", () => {
|
|||||||
});
|
});
|
||||||
expect(result.coverage.status).toBe("partial");
|
expect(result.coverage.status).toBe("partial");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("source to bundle candidate evaluation", () => {
|
||||||
it("evaluates all indexed candidates for each source file", () => {
|
it("evaluates all indexed candidates for each source file", () => {
|
||||||
const result = compareSourceToBundle({
|
const result = compareSourceToBundle({
|
||||||
reference: historicalGraph("complete", ["src/split.ts"]),
|
reference: historicalGraph("complete", ["src/split.ts"]),
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export interface SourceToBundleCandidateIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RELEVANT_NODE_KINDS = new Set([
|
const RELEVANT_NODE_KINDS = new Set([
|
||||||
|
// Non-JavaScript source files such as JSON are projected as artifacts.
|
||||||
|
// Their content digests and paths still identify historical source files.
|
||||||
|
"artifact",
|
||||||
"javascript-asset",
|
"javascript-asset",
|
||||||
"javascript-module",
|
"javascript-module",
|
||||||
"source-module",
|
"source-module",
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { jsonValueSchema } from "../jsonValue.js";
|
||||||
import { parseProcessCapture } from "./processCaptureParsing.js";
|
import { parseProcessCapture } from "./processCaptureParsing.js";
|
||||||
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
|
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
|
||||||
import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js";
|
import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js";
|
||||||
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
|
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
|
||||||
import {
|
import {
|
||||||
capture,
|
capture,
|
||||||
|
emptyCapture,
|
||||||
partialSpecification,
|
partialSpecification,
|
||||||
processStarted,
|
processStarted,
|
||||||
terminal,
|
terminal,
|
||||||
@@ -13,6 +15,35 @@ import {
|
|||||||
|
|
||||||
import { compareProcessCaptures } from "./processComparison.js";
|
import { compareProcessCaptures } from "./processComparison.js";
|
||||||
import { compareProcessTraces } from "./processTraceComparison.js";
|
import { compareProcessTraces } from "./processTraceComparison.js";
|
||||||
|
|
||||||
|
const traced = (
|
||||||
|
left: ReturnType<typeof capture>,
|
||||||
|
right: ReturnType<typeof capture>,
|
||||||
|
) =>
|
||||||
|
compareProcessCaptures(left, right, {
|
||||||
|
traceSpecification: partialSpecification(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const withPayloads = (data: string, command: string) =>
|
||||||
|
capture({
|
||||||
|
...values(["terminal", "process"]),
|
||||||
|
frames: [{ ...terminal, data }],
|
||||||
|
process_samples: [{ ...processStarted, command }],
|
||||||
|
});
|
||||||
|
|
||||||
|
type FiniteTraceLanguage = Extract<
|
||||||
|
ProcessTraceSpecification["language"],
|
||||||
|
{ kind: "finite_traces" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
const finiteTraceSpecification = (
|
||||||
|
events: ProcessTraceSpecification["events"],
|
||||||
|
variants: FiniteTraceLanguage["variants"],
|
||||||
|
): ProcessTraceSpecification => ({
|
||||||
|
events,
|
||||||
|
language: { kind: "finite_traces", variants },
|
||||||
|
});
|
||||||
|
|
||||||
describe("generic capture trace comparison", () => {
|
describe("generic capture trace comparison", () => {
|
||||||
it("requires explicit partial ordering and never infers causality from timestamps", () => {
|
it("requires explicit partial ordering and never infers causality from timestamps", () => {
|
||||||
const specification = partialSpecification();
|
const specification = partialSpecification();
|
||||||
@@ -73,8 +104,8 @@ describe("generic capture trace comparison", () => {
|
|||||||
{ collection: "frames", index: 1 },
|
{ collection: "frames", index: 1 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const specification: ProcessTraceSpecification = {
|
const specification = finiteTraceSpecification(
|
||||||
events: [
|
[
|
||||||
{
|
{
|
||||||
id: "tick1",
|
id: "tick1",
|
||||||
source: "terminal_raw",
|
source: "terminal_raw",
|
||||||
@@ -88,14 +119,11 @@ describe("generic capture trace comparison", () => {
|
|||||||
cardinality: { kind: "optional" },
|
cardinality: { kind: "optional" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
language: {
|
[
|
||||||
kind: "finite_traces",
|
{ id: "one", trace: ["tick1"] },
|
||||||
variants: [
|
{ id: "two", trace: ["tick1", "tick2"] },
|
||||||
{ id: "one", trace: ["tick1"] },
|
],
|
||||||
{ id: "two", trace: ["tick1", "tick2"] },
|
);
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(compareProcessTraces(one, two, specification)).toMatchObject({
|
expect(compareProcessTraces(one, two, specification)).toMatchObject({
|
||||||
verdict: "equivalent",
|
verdict: "equivalent",
|
||||||
left: { matched_variant: "one" },
|
left: { matched_variant: "one" },
|
||||||
@@ -108,8 +136,8 @@ describe("generic capture trace comparison", () => {
|
|||||||
// echoes those identifiers verbatim, so both must accept the same values
|
// echoes those identifiers verbatim, so both must accept the same values
|
||||||
// rather than handing the caller a raw schema error for its own input.
|
// rather than handing the caller a raw schema error for its own input.
|
||||||
const eventId = `e${"0".repeat(64)}`;
|
const eventId = `e${"0".repeat(64)}`;
|
||||||
const specification: ProcessTraceSpecification = {
|
const specification = finiteTraceSpecification(
|
||||||
events: [
|
[
|
||||||
{
|
{
|
||||||
id: eventId,
|
id: eventId,
|
||||||
source: "terminal_raw",
|
source: "terminal_raw",
|
||||||
@@ -117,11 +145,8 @@ describe("generic capture trace comparison", () => {
|
|||||||
cardinality: { kind: "required" },
|
cardinality: { kind: "required" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
language: {
|
[{ id: "only", trace: [eventId] }],
|
||||||
kind: "finite_traces",
|
);
|
||||||
variants: [{ id: "only", trace: [eventId] }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
expect(specificationSchema.safeParse(specification).success).toBe(true);
|
expect(specificationSchema.safeParse(specification).success).toBe(true);
|
||||||
const left = capture(values(["terminal", "process"]));
|
const left = capture(values(["terminal", "process"]));
|
||||||
expect(compareProcessTraces(left, left, specification)).toMatchObject({
|
expect(compareProcessTraces(left, left, specification)).toMatchObject({
|
||||||
@@ -131,6 +156,53 @@ describe("generic capture trace comparison", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("finite trace variant mismatch attribution", () => {
|
||||||
|
it("reports tied best-prefix omissions across variants", () => {
|
||||||
|
const observed = capture({
|
||||||
|
frames: [terminal],
|
||||||
|
process_samples: [],
|
||||||
|
event_journal: [{ collection: "frames", index: 0 }],
|
||||||
|
});
|
||||||
|
const specification = finiteTraceSpecification(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "ready",
|
||||||
|
source: "terminal_raw",
|
||||||
|
exact: terminal,
|
||||||
|
cardinality: { kind: "required" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
source: "process",
|
||||||
|
exact: processStarted,
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker_alt",
|
||||||
|
source: "process",
|
||||||
|
exact: { ...processStarted, command: "alternate-worker" },
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ id: "ready_worker", trace: ["ready", "worker"] },
|
||||||
|
{ id: "ready_worker_alt", trace: ["ready", "worker_alt"] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
compareProcessTraces(observed, observed, specification),
|
||||||
|
).toMatchObject({
|
||||||
|
verdict: "nonconforming",
|
||||||
|
diagnostic: {
|
||||||
|
kind: "trace",
|
||||||
|
event_ids: ["worker", "worker_alt"],
|
||||||
|
locations: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("process trace constraints and evidence sufficiency", () => {
|
describe("process trace constraints and evidence sufficiency", () => {
|
||||||
it("enforces exact and range cardinality for a repeated terminal observation", () => {
|
it("enforces exact and range cardinality for a repeated terminal observation", () => {
|
||||||
const repeated = (count: number) =>
|
const repeated = (count: number) =>
|
||||||
@@ -288,38 +360,255 @@ describe("unknown process trace evidence", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("process trace dimension attribution", () => {
|
describe("process trace dimension attribution", () => {
|
||||||
const traced = (
|
it("applies passing filesystem trace timestamp omissions to exact comparison", () => {
|
||||||
left: ReturnType<typeof capture>,
|
const base = capture(values(["terminal", "process"]));
|
||||||
right: ReturnType<typeof capture>,
|
const left = base;
|
||||||
) =>
|
const right = parseProcessCapture({
|
||||||
compareProcessCaptures(left, right, {
|
...base,
|
||||||
traceSpecification: partialSpecification(),
|
filesystem_checkpoints: base.filesystem_checkpoints.map((checkpoint) =>
|
||||||
|
checkpoint.name === "after_settlement"
|
||||||
|
? { ...checkpoint, at_ms: checkpoint.at_ms + 10 }
|
||||||
|
: checkpoint,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const make = (data: string, command: string) =>
|
const specification = finiteTraceSpecification(
|
||||||
capture({
|
emptyCapture.filesystem_checkpoints.map(
|
||||||
...values(["terminal", "process"]),
|
({ at_ms: _atMs, ...checkpoint }) => ({
|
||||||
frames: [{ ...terminal, data }],
|
id: checkpoint.name,
|
||||||
process_samples: [{ ...processStarted, command }],
|
source: "filesystem" as const,
|
||||||
|
exact: jsonValueSchema.parse(checkpoint),
|
||||||
|
ignore_fields: ["at_ms"],
|
||||||
|
cardinality: { kind: "required" as const },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[{ id: "checkpoints", trace: ["before", "after_settlement"] }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(compareProcessCaptures(left, right)).toMatchObject({
|
||||||
|
filesystem: "changed",
|
||||||
|
first_divergence: { status: "found", dimension: "filesystem" },
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
|
compareProcessCaptures(left, right, {
|
||||||
|
traceSpecification: specification,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
trace: { verdict: "equivalent" },
|
||||||
|
filesystem: "unchanged",
|
||||||
|
first_divergence: { status: "none" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalFile = {
|
||||||
|
path: "root_0:created.txt",
|
||||||
|
type: "file" as const,
|
||||||
|
mode: 0o644,
|
||||||
|
size: 4,
|
||||||
|
sha256: "a".repeat(64),
|
||||||
|
symlink_target: null,
|
||||||
|
};
|
||||||
|
const created = {
|
||||||
|
path: finalFile.path,
|
||||||
|
status: "created" as const,
|
||||||
|
before: null,
|
||||||
|
after: finalFile,
|
||||||
|
};
|
||||||
|
const changedRight = parseProcessCapture({
|
||||||
|
...right,
|
||||||
|
filesystem_checkpoints: right.filesystem_checkpoints.map((checkpoint) =>
|
||||||
|
checkpoint.name === "after_settlement"
|
||||||
|
? { ...checkpoint, files: [finalFile], effects: [created] }
|
||||||
|
: checkpoint,
|
||||||
|
),
|
||||||
|
files_after: [finalFile],
|
||||||
|
filesystem_effects: [created],
|
||||||
|
truncation_details: {
|
||||||
|
...right.truncation_details,
|
||||||
|
filesystem_after: {
|
||||||
|
...right.truncation_details.filesystem_after,
|
||||||
|
hashed_bytes: finalFile.size,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
compareProcessCaptures(left, changedRight, {
|
||||||
|
traceSpecification: specification,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
trace: { verdict: "different" },
|
||||||
|
filesystem: "changed",
|
||||||
|
first_divergence: { status: "found", dimension: "filesystem" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps equal dimensions unchanged when one side fails a predicate", () => {
|
it("keeps equal dimensions unchanged when one side fails a predicate", () => {
|
||||||
expect(
|
expect(
|
||||||
traced(make("Ready", "worker"), make("Different", "worker")),
|
traced(
|
||||||
|
withPayloads("Ready", "worker"),
|
||||||
|
withPayloads("Different", "worker"),
|
||||||
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
trace: { verdict: "different" },
|
trace: { verdict: "different" },
|
||||||
terminal: "changed",
|
terminal: "changed",
|
||||||
process: "unchanged",
|
process: "unchanged",
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
traced(make("Ready", "worker"), make("Ready", "other-worker")),
|
traced(
|
||||||
|
withPayloads("Ready", "worker"),
|
||||||
|
withPayloads("Ready", "other-worker"),
|
||||||
|
),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
trace: { verdict: "different" },
|
trace: { verdict: "different" },
|
||||||
terminal: "unchanged",
|
terminal: "unchanged",
|
||||||
process: "changed",
|
process: "changed",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("still attributes an ordering difference to both ordered dimensions", () => {
|
describe("finite trace dimension attribution", () => {
|
||||||
|
it("attributes a finite-trace omission to the omitted event dimension", () => {
|
||||||
|
const left = capture({
|
||||||
|
frames: [terminal],
|
||||||
|
process_samples: [],
|
||||||
|
event_journal: [{ collection: "frames", index: 0 }],
|
||||||
|
});
|
||||||
|
const right = capture(values(["terminal", "process"]));
|
||||||
|
const specification = finiteTraceSpecification(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "ready",
|
||||||
|
source: "terminal_raw",
|
||||||
|
exact: terminal,
|
||||||
|
cardinality: { kind: "required" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
source: "process",
|
||||||
|
exact: processStarted,
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[{ id: "ready_worker", trace: ["ready", "worker"] }],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
compareProcessCaptures(left, right, {
|
||||||
|
traceSpecification: specification,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "changed",
|
||||||
|
process: "changed",
|
||||||
|
terminal: "unchanged",
|
||||||
|
trace: {
|
||||||
|
verdict: "different",
|
||||||
|
diagnostic: { kind: "trace", event_ids: ["worker"], locations: [] },
|
||||||
|
},
|
||||||
|
first_divergence: {
|
||||||
|
status: "found",
|
||||||
|
dimension: "process",
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unordered finite trace mismatch attribution", () => {
|
||||||
|
it("attributes only the missing member of an unordered group", () => {
|
||||||
|
const db = { sequence: 1, at_ms: 901, data: "DB" };
|
||||||
|
const alert = { ...processStarted, pid: 2, command: "other-worker" };
|
||||||
|
const left = capture({
|
||||||
|
frames: [terminal],
|
||||||
|
process_samples: [processStarted],
|
||||||
|
event_journal: [
|
||||||
|
{ collection: "frames", index: 0 },
|
||||||
|
{ collection: "process_samples", index: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const right = capture({
|
||||||
|
frames: [terminal, db],
|
||||||
|
process_samples: [processStarted],
|
||||||
|
event_journal: [
|
||||||
|
{ collection: "frames", index: 0 },
|
||||||
|
{ collection: "process_samples", index: 0 },
|
||||||
|
{ collection: "frames", index: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const specification = finiteTraceSpecification(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "ready",
|
||||||
|
source: "terminal_raw",
|
||||||
|
exact: terminal,
|
||||||
|
cardinality: { kind: "required" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
source: "process",
|
||||||
|
exact: processStarted,
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "db",
|
||||||
|
source: "terminal_raw",
|
||||||
|
exact: db,
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert",
|
||||||
|
source: "process",
|
||||||
|
exact: alert,
|
||||||
|
cardinality: { kind: "optional" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "ready_then_work",
|
||||||
|
trace: ["ready", { unordered: ["worker", "db"] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
compareProcessCaptures(left, right, {
|
||||||
|
traceSpecification: specification,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
trace: {
|
||||||
|
verdict: "different",
|
||||||
|
diagnostic: {
|
||||||
|
side: "left",
|
||||||
|
event_ids: ["db"],
|
||||||
|
locations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
terminal: "changed",
|
||||||
|
process: "unchanged",
|
||||||
|
first_divergence: { status: "found", dimension: "terminal", index: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const unexpected = capture({
|
||||||
|
frames: [terminal],
|
||||||
|
process_samples: [processStarted, alert],
|
||||||
|
event_journal: [
|
||||||
|
{ collection: "frames", index: 0 },
|
||||||
|
{ collection: "process_samples", index: 0 },
|
||||||
|
{ collection: "process_samples", index: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
compareProcessTraces(unexpected, unexpected, specification),
|
||||||
|
).toMatchObject({
|
||||||
|
verdict: "nonconforming",
|
||||||
|
diagnostic: {
|
||||||
|
kind: "trace",
|
||||||
|
event_ids: ["db", "alert"],
|
||||||
|
locations: [{ collection: "process_samples", index: 1 }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("partial-order dimension attribution", () => {
|
||||||
|
it("attributes an ordering difference to both ordered dimensions", () => {
|
||||||
expect(
|
expect(
|
||||||
traced(
|
traced(
|
||||||
capture(values(["terminal", "process"])),
|
capture(values(["terminal", "process"])),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const traceCoversObservedDimension = (
|
|||||||
case "exit":
|
case "exit":
|
||||||
return sources.has("lifecycle");
|
return sources.has("lifecycle");
|
||||||
case "filesystem":
|
case "filesystem":
|
||||||
return false;
|
return sources.has("filesystem");
|
||||||
case "process":
|
case "process":
|
||||||
return sources.has("process");
|
return sources.has("process");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,29 +123,92 @@ const failure = (input: FailureInput): EvaluatedSide => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type FiniteTraceEvaluation =
|
||||||
|
| { readonly status: "pass"; readonly variant: string }
|
||||||
|
| {
|
||||||
|
readonly status: "fail";
|
||||||
|
readonly eventIds: readonly string[];
|
||||||
|
readonly locationIndexes: readonly number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Match variants once, retaining the best-prefix mismatch for attribution. */
|
||||||
const evaluateFiniteTrace = (
|
const evaluateFiniteTrace = (
|
||||||
ids: readonly string[],
|
ids: readonly string[],
|
||||||
specification: ProcessTraceSpecification,
|
language: Extract<
|
||||||
): string | null => {
|
ProcessTraceSpecification["language"],
|
||||||
if (specification.language.kind !== "finite_traces") return null;
|
{ kind: "finite_traces" }
|
||||||
for (const variant of specification.language.variants) {
|
>,
|
||||||
let offset = 0;
|
): FiniteTraceEvaluation => {
|
||||||
let accepted = true;
|
let bestPrefix = -1;
|
||||||
for (const token of variant.trace) {
|
const bestEventIds = new Set<string>();
|
||||||
if (typeof token === "string") {
|
const bestLocationIndexes = new Set<number>();
|
||||||
if (ids[offset] !== token) accepted = false;
|
|
||||||
offset += 1;
|
const recordMismatch = (
|
||||||
continue;
|
offset: number,
|
||||||
}
|
missing: readonly string[],
|
||||||
const actual = ids.slice(offset, offset + token.unordered.length).sort();
|
unexpected: readonly { readonly id: string; readonly index: number }[],
|
||||||
const expected = [...token.unordered].sort();
|
): void => {
|
||||||
if (canonicalTraceJson(actual) !== canonicalTraceJson(expected))
|
if (offset > bestPrefix) {
|
||||||
accepted = false;
|
bestPrefix = offset;
|
||||||
offset += token.unordered.length;
|
bestEventIds.clear();
|
||||||
|
bestLocationIndexes.clear();
|
||||||
}
|
}
|
||||||
if (accepted && offset === ids.length) return variant.id;
|
if (offset !== bestPrefix) return;
|
||||||
|
for (const eventId of missing) bestEventIds.add(eventId);
|
||||||
|
for (const { id, index } of unexpected) {
|
||||||
|
bestEventIds.add(id);
|
||||||
|
bestLocationIndexes.add(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const variant of language.variants) {
|
||||||
|
let offset = 0;
|
||||||
|
let mismatched = false;
|
||||||
|
for (const token of variant.trace) {
|
||||||
|
const expected = typeof token === "string" ? [token] : token.unordered;
|
||||||
|
const actual = ids.slice(offset, offset + expected.length);
|
||||||
|
const remaining = new Map<string, number>();
|
||||||
|
for (const eventId of expected)
|
||||||
|
remaining.set(eventId, (remaining.get(eventId) ?? 0) + 1);
|
||||||
|
const unexpected: Array<{ readonly id: string; readonly index: number }> =
|
||||||
|
[];
|
||||||
|
for (const [index, eventId] of actual.entries()) {
|
||||||
|
const count = remaining.get(eventId) ?? 0;
|
||||||
|
if (count === 0)
|
||||||
|
unexpected.push({ id: eventId, index: offset + index });
|
||||||
|
else if (count === 1) remaining.delete(eventId);
|
||||||
|
else remaining.set(eventId, count - 1);
|
||||||
|
}
|
||||||
|
const missing = [...remaining].flatMap(([eventId, count]) =>
|
||||||
|
Array.from({ length: count }, () => eventId),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
missing.length > 0 ||
|
||||||
|
unexpected.length > 0 ||
|
||||||
|
actual.length < expected.length
|
||||||
|
) {
|
||||||
|
recordMismatch(offset, missing, unexpected);
|
||||||
|
mismatched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offset += expected.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mismatched) continue;
|
||||||
|
if (offset === ids.length) return { status: "pass", variant: variant.id };
|
||||||
|
const unexpected = ids[offset];
|
||||||
|
recordMismatch(
|
||||||
|
offset,
|
||||||
|
[],
|
||||||
|
unexpected === undefined ? [] : [{ id: unexpected, index: offset }],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return {
|
||||||
|
status: "fail",
|
||||||
|
eventIds: [...bestEventIds],
|
||||||
|
locationIndexes: [...bestLocationIndexes],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MatchedTrace = {
|
type MatchedTrace = {
|
||||||
@@ -391,20 +454,23 @@ const evaluateMatchedTrace = (
|
|||||||
if (specification.language.kind === "partial_order")
|
if (specification.language.kind === "partial_order")
|
||||||
return evaluatePartialOrder(trace, specification.language);
|
return evaluatePartialOrder(trace, specification.language);
|
||||||
const ids = trace.rawTrace.map(({ event_id }) => event_id);
|
const ids = trace.rawTrace.map(({ event_id }) => event_id);
|
||||||
const variant = evaluateFiniteTrace(ids, specification);
|
const evaluation = evaluateFiniteTrace(ids, specification.language);
|
||||||
return variant === null
|
return evaluation.status === "fail"
|
||||||
? failure({
|
? failure({
|
||||||
kind: "trace",
|
kind: "trace",
|
||||||
message: "Observed events do not match any finite trace variant.",
|
message: "Observed events do not match any finite trace variant.",
|
||||||
eventIds: ids,
|
eventIds: evaluation.eventIds,
|
||||||
locations: trace.rawTrace.map(({ location }) => location),
|
locations: evaluation.locationIndexes.flatMap((index) => {
|
||||||
|
const location = trace.rawTrace[index]?.location;
|
||||||
|
return location === undefined ? [] : [location];
|
||||||
|
}),
|
||||||
rawTrace: trace.rawTrace,
|
rawTrace: trace.rawTrace,
|
||||||
})
|
})
|
||||||
: {
|
: {
|
||||||
result: {
|
result: {
|
||||||
status: "pass",
|
status: "pass",
|
||||||
matched_variant: variant,
|
matched_variant: evaluation.variant,
|
||||||
satisfied_constraints: [`variant:${variant}`],
|
satisfied_constraints: [`variant:${evaluation.variant}`],
|
||||||
raw_trace: trace.rawTrace,
|
raw_trace: trace.rawTrace,
|
||||||
},
|
},
|
||||||
diagnostic: null,
|
diagnostic: null,
|
||||||
|
|||||||
@@ -323,11 +323,60 @@ describe("historical calls-only reconstruction changes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("function reconstruction call coverage", () => {
|
describe("function reconstruction call coverage", () => {
|
||||||
|
it("preserves complete call diagnostics while passing equal call claims", () => {
|
||||||
|
const diagnostic = "Indirect calls remain unresolved.";
|
||||||
|
const leftWithDiagnostic = source("a", {
|
||||||
|
relationshipCoverage: { status: "complete", diagnostics: [diagnostic] },
|
||||||
|
});
|
||||||
|
const rightWithDiagnostic = source("b", {
|
||||||
|
relationshipCoverage: { status: "complete", diagnostics: [diagnostic] },
|
||||||
|
});
|
||||||
|
const observed = compareFunctions(leftWithDiagnostic, rightWithDiagnostic);
|
||||||
|
expect(
|
||||||
|
observed.dimensions.find(({ dimension }) => dimension === "calls"),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "unchanged",
|
||||||
|
limitations: [`Left: ${diagnostic}`, `Right: ${diagnostic}`],
|
||||||
|
});
|
||||||
|
const evidence = comparison(
|
||||||
|
{
|
||||||
|
left_evidence_id: leftWithDiagnostic.evidence_id,
|
||||||
|
right_evidence_id: rightWithDiagnostic.evidence_id,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
[leftWithDiagnostic, rightWithDiagnostic],
|
||||||
|
{},
|
||||||
|
observed,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
admittedVerification(
|
||||||
|
evidence,
|
||||||
|
[leftWithDiagnostic, rightWithDiagnostic],
|
||||||
|
"calls",
|
||||||
|
)(),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "pass",
|
||||||
|
claims: {
|
||||||
|
items: [
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "pass",
|
||||||
|
observed_status: "unchanged",
|
||||||
|
limitations: expect.arrayContaining([
|
||||||
|
`Source ${leftWithDiagnostic.evidence_id}: ${diagnostic}`,
|
||||||
|
`Source ${rightWithDiagnostic.evidence_id}: ${diagnostic}`,
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => {
|
it("rechecks old unchanged comparisons without affecting unrelated finite dimensions", () => {
|
||||||
const incomplete = source("7", {
|
const incomplete = source("7", {
|
||||||
relationshipCoverage: {
|
relationshipCoverage: {
|
||||||
status: "unknown",
|
status: "unknown",
|
||||||
reason: "Call graph unavailable.",
|
reason: "Call graph unavailable.",
|
||||||
|
diagnostics: ["Indirect calls remain unresolved."],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const incompleteSides = {
|
const incompleteSides = {
|
||||||
@@ -352,6 +401,7 @@ describe("function reconstruction call coverage", () => {
|
|||||||
observed_status: "unchanged",
|
observed_status: "unchanged",
|
||||||
limitations: expect.arrayContaining([
|
limitations: expect.arrayContaining([
|
||||||
`Source ${incomplete.evidence_id}: Call graph unavailable.`,
|
`Source ${incomplete.evidence_id}: Call graph unavailable.`,
|
||||||
|
`Source ${incomplete.evidence_id}: Indirect calls remain unresolved.`,
|
||||||
]),
|
]),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
isProcessEvidenceProvider,
|
isProcessEvidenceProvider,
|
||||||
} from "./process/processEvidenceProvider.js";
|
} from "./process/processEvidenceProvider.js";
|
||||||
import { functionDossierSchema } from "./hopperValues.js";
|
import { functionDossierSchema } from "./hopperValues.js";
|
||||||
import { functionCallCoverageLimitations } from "./functionCallCoverage.js";
|
import { assessFunctionCallCoverage } from "./functionCallCoverage.js";
|
||||||
import {
|
import {
|
||||||
deriveProcessComparisonStatus,
|
deriveProcessComparisonStatus,
|
||||||
PROCESS_COMPARISON_DIMENSIONS,
|
PROCESS_COMPARISON_DIMENSIONS,
|
||||||
@@ -64,7 +64,8 @@ const providers = {
|
|||||||
|
|
||||||
interface FunctionSourceFacts {
|
interface FunctionSourceFacts {
|
||||||
readonly evidence_id: string;
|
readonly evidence_id: string;
|
||||||
readonly callCoverageLimitations: readonly string[];
|
readonly callCoverageBlockers: readonly string[];
|
||||||
|
readonly callCoverageDiagnostics: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptsPredicate = (
|
const acceptsPredicate = (
|
||||||
@@ -149,13 +150,14 @@ const evaluateClaim = (
|
|||||||
const functionSourceFacts = readValidatedSourceFacts(claim, sources);
|
const functionSourceFacts = readValidatedSourceFacts(claim, sources);
|
||||||
const observation = comparisonObservation(claim, comparison);
|
const observation = comparisonObservation(claim, comparison);
|
||||||
const observed = observation.status;
|
const observed = observation.status;
|
||||||
const limitations = [
|
const callCoverage = sourceCallCoverage(
|
||||||
|
claim,
|
||||||
|
functionSourceFacts,
|
||||||
|
observation.hasIndependentFunctionChange === true,
|
||||||
|
);
|
||||||
|
const blockers = [
|
||||||
...authorityLimitations(claim, sources, sides),
|
...authorityLimitations(claim, sources, sides),
|
||||||
...sourceCallCoverageLimitations(
|
...callCoverage.blockers,
|
||||||
claim,
|
|
||||||
functionSourceFacts,
|
|
||||||
observation.hasIndependentFunctionChange === true,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
const relatedUnknowns = reconstructionClaimUnknowns(
|
const relatedUnknowns = reconstructionClaimUnknowns(
|
||||||
heads,
|
heads,
|
||||||
@@ -164,7 +166,7 @@ const evaluateClaim = (
|
|||||||
claim.dimension,
|
claim.dimension,
|
||||||
);
|
);
|
||||||
const relevantUnknowns = relatedUnknowns;
|
const relevantUnknowns = relatedUnknowns;
|
||||||
const status = classify(observed, limitations, relevantUnknowns);
|
const status = classify(observed, blockers, relevantUnknowns);
|
||||||
const displayedUnknowns = relevantUnknowns;
|
const displayedUnknowns = relevantUnknowns;
|
||||||
return reconstructionClaimResultSchema.parse({
|
return reconstructionClaimResultSchema.parse({
|
||||||
claim_id: claim.claim_id,
|
claim_id: claim.claim_id,
|
||||||
@@ -180,7 +182,8 @@ const evaluateClaim = (
|
|||||||
limitations: uniqueSorted([
|
limitations: uniqueSorted([
|
||||||
...comparison.limitations,
|
...comparison.limitations,
|
||||||
...observation.limitations,
|
...observation.limitations,
|
||||||
...limitations,
|
...blockers,
|
||||||
|
...callCoverage.diagnostics,
|
||||||
...displayedUnknowns.map(
|
...displayedUnknowns.map(
|
||||||
({ question }) => `Residual unknown: ${question}`,
|
({ question }) => `Residual unknown: ${question}`,
|
||||||
),
|
),
|
||||||
@@ -214,12 +217,14 @@ const readValidatedSourceFacts = (
|
|||||||
parseProcessCapture(source.normalized_result);
|
parseProcessCapture(source.normalized_result);
|
||||||
else if (claim.kind === "structural-function") {
|
else if (claim.kind === "structural-function") {
|
||||||
const dossier = functionDossierSchema.parse(source.normalized_result);
|
const dossier = functionDossierSchema.parse(source.normalized_result);
|
||||||
|
const callCoverage = assessFunctionCallCoverage(
|
||||||
|
source.relationship_coverage,
|
||||||
|
dossier,
|
||||||
|
);
|
||||||
functionSources.push({
|
functionSources.push({
|
||||||
evidence_id: source.evidence_id,
|
evidence_id: source.evidence_id,
|
||||||
callCoverageLimitations: functionCallCoverageLimitations(
|
callCoverageBlockers: callCoverage.blockers,
|
||||||
source.relationship_coverage,
|
callCoverageDiagnostics: callCoverage.diagnostics,
|
||||||
dossier,
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} else artifactInventoryResultSchema.parse(source.normalized_result);
|
} else artifactInventoryResultSchema.parse(source.normalized_result);
|
||||||
}
|
}
|
||||||
@@ -366,20 +371,28 @@ const authorityLimitations = (
|
|||||||
return limitations;
|
return limitations;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceCallCoverageLimitations = (
|
const sourceCallCoverage = (
|
||||||
claim: Claim,
|
claim: Claim,
|
||||||
functionSources: readonly FunctionSourceFacts[],
|
functionSources: readonly FunctionSourceFacts[],
|
||||||
hasIndependentFunctionChange: boolean,
|
hasIndependentFunctionChange: boolean,
|
||||||
): string[] => {
|
): { blockers: string[]; diagnostics: string[] } => {
|
||||||
if (
|
if (
|
||||||
claim.kind !== "structural-function" ||
|
claim.kind !== "structural-function" ||
|
||||||
(claim.dimension !== "calls" &&
|
(claim.dimension !== "calls" &&
|
||||||
!(claim.dimension === "overall" && !hasIndependentFunctionChange))
|
!(claim.dimension === "overall" && !hasIndependentFunctionChange))
|
||||||
)
|
)
|
||||||
return [];
|
return { blockers: [], diagnostics: [] };
|
||||||
return functionSources.flatMap(({ evidence_id, callCoverageLimitations }) =>
|
return {
|
||||||
callCoverageLimitations.map((reason) => `Source ${evidence_id}: ${reason}`),
|
blockers: functionSources.flatMap(({ evidence_id, callCoverageBlockers }) =>
|
||||||
);
|
callCoverageBlockers.map((reason) => `Source ${evidence_id}: ${reason}`),
|
||||||
|
),
|
||||||
|
diagnostics: functionSources.flatMap(
|
||||||
|
({ evidence_id, callCoverageDiagnostics }) =>
|
||||||
|
callCoverageDiagnostics.map(
|
||||||
|
(reason) => `Source ${evidence_id}: ${reason}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const classify = (
|
const classify = (
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ describe("web bundle analyzer", () => {
|
|||||||
trust: "page-declared-untrusted",
|
trust: "page-declared-untrusted",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("web bundle source-map coverage", () => {
|
||||||
it("reports unavailable source maps as partial without dropping requested maps", () => {
|
it("reports unavailable source maps as partial without dropping requested maps", () => {
|
||||||
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
|
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
@@ -191,6 +193,18 @@ describe("web bundle analyzer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps map-less captured source complete when no maps were requested", () => {
|
||||||
|
const analysis = analyzeCapturedWebBundle(inspection("export {};"), {
|
||||||
|
status: "not_requested",
|
||||||
|
requested: 0,
|
||||||
|
processed: 0,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(analysis.completeness.status).toBe("complete");
|
||||||
|
expect(analysis.unknowns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects inconsistent source-map coverage counts", () => {
|
it("rejects inconsistent source-map coverage counts", () => {
|
||||||
const result = analyzeCapturedWebBundle(inspection("export {};"));
|
const result = analyzeCapturedWebBundle(inspection("export {};"));
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -55,9 +55,3 @@ export const zipPackageFormatForPath = (
|
|||||||
/** Inventory lists entries, not all bytes surrounding the central directory. */
|
/** Inventory lists entries, not all bytes surrounding the central directory. */
|
||||||
export const ZIP_NON_ENTRY_LIMITATION =
|
export const ZIP_NON_ENTRY_LIMITATION =
|
||||||
"ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.";
|
"ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.";
|
||||||
|
|
||||||
/** Recognize the same inventory scope in current and previously retained Evidence. */
|
|
||||||
export const isZipInventoryScopeLimitation = (limitation: string): boolean =>
|
|
||||||
limitation === ZIP_NON_ENTRY_LIMITATION ||
|
|
||||||
limitation ===
|
|
||||||
"ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences.";
|
|
||||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user