Compare commits

...
Author SHA1 Message Date
Kaoru0822andClaude Opus 5.5 c6cda443c1 docs(javascript): state that duplicate unmapped content stays ambiguous
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 03:33:36 +00:00
Kaoru0822andClaude Opus 5.5 e308f35c43 fix(javascript): keep unmapped captured runtime scripts unknown
A captured script whose bytes matched no static digest was reported as
high-confidence unmatched even when no automatic or caller-declared
mapping applied to its location in any static layer. The matcher now
computes mapping presence once while locating candidates and shares one
no-candidate classification between the captured-source and source-free
branches, so an unmapped location stays unknown with
no-authorized-location-mapping and is not counted as unmatched.

Fixes #6061.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 02:20:21 +00:00
morluto 719d56ebfe fix(evidence): preserve provider execution provenance across workflows 2026-10-22 10:02:31 +08:00
morluto 87c8086fc1 refactor(managed): reuse canonical JSON digest owner 2026-10-22 09:52:58 +08:00
morluto eab4e47916 refactor(ghidra): share session startup observation checks 2026-10-22 09:52:49 +08:00
morluto db87917daa refactor(native): use canonical deadline defaults 2026-10-22 09:52:40 +08:00
morluto 183b4d4e7e refactor(managed): share metadata token schema 2026-10-22 09:33:58 +08:00
morluto 00819258a7 refactor(browser): separate bundle state from AST inspection 2026-10-22 09:33:58 +08:00
41 changed files with 945 additions and 469 deletions

No files matched your search

+6 -1
View File
@@ -106,7 +106,12 @@ nodes. REA evaluates compatible static candidates conservatively:
that disagreement. If a mapped candidate's digest is unavailable and no
mapped content match is known, the comparison remains `unknown`.
6. Missing authorization mappings and incomplete or truncated input coverage
remain unknown or truncated rather than becoming absence claims.
remain unknown or truncated rather than becoming absence claims. A runtime
location with no automatic artifact-root or caller-declared mapping into
any layer, and no content match, is `unknown` with
`no-authorized-location-mapping`, with or without captured bytes; several
content matches remain `ambiguous` under rule 4. Only a mapped location can
be `unmatched` with `no-static-content-match`.
Every successful cross-layer `observed_as` edge has
`authority: cross-layer-reconciliation` and `state: inferred`. The underlying
@@ -0,0 +1,102 @@
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import { PacketCaptureService } from "./PacketCaptureService.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { inspectPacketCaptureInputSchema } from "../../domain/captures/packetCapture.js";
import { ok } from "../../domain/result.js";
it("retains provider metadata in a late-cancellation observation", async () => {
const input = inspectPacketCaptureInputSchema.parse({
path: process.platform === "win32" ? "C:\\capture.pcap" : "/capture.pcap",
fields: ["ip.addr"],
});
const provider = {
id: "fixture-capture",
name: "Fixture capture provider",
version: "1",
} as const;
const subject = {
path: input.path,
sha256: "a".repeat(64),
format: "file",
} as const;
const report = {
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
selection: input,
packets: [],
output_window: {
matching_packets: 0,
returned_packets: 0,
has_more: false,
},
read_scope: "to-eof",
diagnostics: "",
tool: {
configured_path: "/fixture/tshark",
path: "/fixture/tshark",
sha256: "b".repeat(64),
version_banner: "TShark fixture",
version_diagnostics: "",
arguments: [],
},
configuration: "private-empty-personal-profile",
resource_limiter: {
configured_path: "/fixture/prlimit",
path: "/fixture/prlimit",
sha256: "c".repeat(64),
arguments: [],
},
limits: {
input_bytes: 1,
output_bytes: 1,
command_timeout_ms: 1,
address_space_bytes: 0,
file_bytes: 0,
cpu_seconds: 0,
},
hardware_acquisition: "not-performed",
} as const;
const rawResult = { producer: "fixture", bytes: 12 };
const profile = createAnalysisProfile(provider, { filter: null });
const relationshipCoverage = {
status: "unknown",
reason: "Packet relationships are not reported.",
} as const;
const execution = createAnalysisExecution(report, provider, {
subject,
rawResult,
analysisProfile: profile,
relationshipCoverage,
locations: [{ kind: "artifact-path", path: input.path }],
});
const controller = new AbortController();
const reason = Object.freeze({ requested: "cancel after capture result" });
const service = new PacketCaptureService({
inspect: async (_input, options?: ExecutionOptions) => {
controller.abort(reason);
expect(options?.signal).toBe(controller.signal);
return ok(execution);
},
inspectFields: async () => {
throw new Error("Unexpected field discovery call");
},
close: async () => {},
});
const result = await service.inspect(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
operation: "inspect_packet_capture",
raw_result: rawResult,
analysis_profile: profile,
relationship_coverage: relationshipCoverage,
});
});
@@ -3,24 +3,25 @@ import {
packetFieldsCatalogSchema,
type InspectPacketFieldsInput,
} from "../../domain/captures/packetFields.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type {
AnalysisExecution,
ExecutionOptions,
import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
inspectPacketCaptureInputSchema,
packetCaptureSchema,
type InspectPacketCaptureInput,
} from "../../domain/captures/packetCapture.js";
import { err, ok, type Result } from "../../domain/result.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
export interface PacketCapturePort {
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
@@ -74,7 +75,15 @@ export class PacketCaptureService {
"Capture analyzer returned malformed data or changed the selected artifact identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
input.data,
execution,
"observed",
),
options?.signal,
);
}
async inspectFields(
raw: unknown,
@@ -108,33 +117,14 @@ export class PacketCaptureService {
"Field discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
input.data,
execution,
"observed",
),
options?.signal,
);
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: evidence,
}),
)
: ok(evidence);
}
}
@@ -8,6 +8,7 @@ import {
} from "../../domain/firmware/firmwareAnalysis.js";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import { err, ok } from "../../domain/result.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
const fixture = (
operation: FirmwareOperation,
@@ -68,15 +69,35 @@ it.each(["inspect_firmware_regions", "extract_firmware"] as const)(
"retains %s Evidence after late cancellation and preserves provider failure precedence",
async (operation) => {
const { input, execution } = fixture(operation);
const profile = createAnalysisProfile(
{ ...execution.provider, version: "1" },
{
signature_database: "caller-selected snapshot",
},
);
const coverage = {
status: "unknown",
reason: "No call graph examined",
} as const;
const observation = {
...execution,
analysisProfile: profile,
relationshipCoverage: coverage,
};
const control = await new FirmwareAnalysisService({
execute: async () => ok(execution),
execute: async () => ok(observation),
}).execute(operation, input);
if (!control.ok) throw control.error;
expect(control.value).toMatchObject({
raw_result: { source: "controlled producer" },
analysis_profile: profile,
relationship_coverage: coverage,
});
const controller = new AbortController();
const result = await new FirmwareAnalysisService({
execute: async () => {
controller.abort();
return ok(execution);
return ok(observation);
},
}).execute(operation, input, { signal: controller.signal });
expect(result).toMatchObject({
@@ -1,4 +1,7 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
createAnalysisExecutionEvidence,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { FirmwareAnalysisPort } from "./FirmwareAnalysisPort.js";
import {
firmwareRequestSchema,
@@ -12,8 +15,7 @@ import {
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import type { Evidence } from "../../domain/evidence.js";
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
@@ -71,15 +73,12 @@ export class FirmwareAnalysisService {
),
);
return completeProviderEvidence(
createEvidence(execution.subject, execution.provider, {
createAnalysisExecutionEvidence(
operation,
parameters: jsonObjectSchema.parse(parsed.data.input),
result: execution.result,
rawResult: execution.rawResult,
locations: execution.locations,
limitations: execution.limitations,
confidence: "observed",
}),
parsed.data.input,
execution,
"observed",
),
options?.signal,
);
}
@@ -0,0 +1,96 @@
import { expect, it } from "vitest";
import {
createAnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import { SignalRecordingService } from "./SignalRecordingService.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
import { decodeSignalRecordingInputSchema } from "../../domain/signals/signalRecording.js";
import { ok } from "../../domain/result.js";
it("retains provider metadata in a late-cancellation observation", async () => {
const input = decodeSignalRecordingInputSchema.parse({
path: process.platform === "win32" ? "C:\\recording.vcd" : "/recording.vcd",
decoders: "uart",
});
const provider = {
id: "fixture-signal",
name: "Fixture signal provider",
version: "1",
} as const;
const subject = {
path: input.path,
sha256: "a".repeat(64),
format: "file",
} as const;
const report = {
artifact: { path: input.path, sha256: subject.sha256, bytes: 12 },
selection: input,
annotations_text: "10-20 uart-1: 42\n",
output_window: { total_lines: 1, returned_lines: 1, has_more: false },
diagnostics: "",
tool: {
configured_path: "/fixture/sigrok-cli",
path: "/fixture/sigrok-cli",
sha256: "b".repeat(64),
version_banner: "sigrok-cli fixture",
version_diagnostics: "",
arguments: [],
},
resource_limiter: {
configured_path: "/fixture/prlimit",
path: "/fixture/prlimit",
sha256: "c".repeat(64),
arguments: [],
},
limits: {
input_bytes: 1,
output_bytes: 1,
command_timeout_ms: 1,
address_space_bytes: 0,
file_bytes: 0,
cpu_seconds: 0,
},
hardware_acquisition: "not-performed",
} as const;
const rawResult = { producer: "fixture", bytes: 12 };
const profile = createAnalysisProfile(provider, { decoder: "uart" });
const relationshipCoverage = {
status: "unknown",
reason: "Recording relationships are not reported.",
} as const;
const execution = createAnalysisExecution(report, provider, {
subject,
rawResult,
analysisProfile: profile,
relationshipCoverage,
locations: [{ kind: "artifact-path", path: input.path }],
});
const controller = new AbortController();
const reason = Object.freeze({ requested: "cancel after recording result" });
const service = new SignalRecordingService({
decode: async (_input, options?: ExecutionOptions) => {
controller.abort(reason);
expect(options?.signal).toBe(controller.signal);
return ok(execution);
},
inspectDecoders: async () => {
throw new Error("Unexpected decoder discovery call");
},
close: async () => {},
});
const result = await service.decode(input, { signal: controller.signal });
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected late cancellation");
expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" });
expect(result.error.cause).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
operation: "decode_signal_recording",
raw_result: rawResult,
analysis_profile: profile,
relationship_coverage: relationshipCoverage,
});
});
@@ -3,24 +3,25 @@ import {
signalDecodersSchema,
type InspectSignalDecodersInput,
} from "../../domain/signals/signalDecoders.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type {
AnalysisExecution,
ExecutionOptions,
import {
createAnalysisExecutionEvidence,
type AnalysisExecution,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
import {
decodeSignalRecordingInputSchema,
signalRecordingSchema,
type DecodeSignalRecordingInput,
} from "../../domain/signals/signalRecording.js";
import { err, ok, type Result } from "../../domain/result.js";
import { err, type Result } from "../../domain/result.js";
import { completeProviderEvidence } from "../ProviderOperationExecution.js";
export interface SignalRecordingPort {
/** Release per-call resources before returning, or retain failed cleanup for close/retry. */
@@ -74,7 +75,15 @@ export class SignalRecordingService {
"Decoder returned malformed data or changed the selected recording identity/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
input.data,
execution,
"observed",
),
options?.signal,
);
}
async inspectDecoders(
raw: unknown,
@@ -108,33 +117,14 @@ export class SignalRecordingService {
"Decoder discovery returned malformed data or changed the selected installation/options.",
),
);
return this.#evidence(operation, input.data, execution, options);
}
#evidence(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
execution: AnalysisExecution,
options?: ExecutionOptions,
): Result<Evidence, AnalysisError> {
const evidence = createEvidence(
execution.subject ?? undefined,
execution.provider,
{
return completeProviderEvidence(
createAnalysisExecutionEvidence(
operation,
parameters,
result: execution.result,
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
},
input.data,
execution,
"observed",
),
options?.signal,
);
return options?.signal?.aborted
? err(
new AnalysisCancelledError(operation, {
cause: options.signal.reason,
partialObservation: evidence,
}),
)
: ok(evidence);
}
}
@@ -5,6 +5,7 @@ import {
wasmArtifactExecution,
} from "../../../tests/fixtures/wasm/artifact.js";
import { ok } from "../../domain/result.js";
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
it.each([
{ path: "relative" },
{ path: "/module", glue_paths: ["relative"] },
@@ -119,30 +120,54 @@ it("cancels before provider work and delivers observed Evidence on success", asy
});
});
it("retains completed Evidence when cancellation arrives after provider return", async () => {
const value = wasmArtifactFixture();
const controller = new AbortController();
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async () => {
controller.abort();
return ok(wasmArtifactExecution(value));
},
});
it.each([false, true])(
"retains provider provenance after completion (late cancellation: %s)",
async (cancel) => {
const value = wasmArtifactFixture();
const execution = wasmArtifactExecution(value);
const profile = createAnalysisProfile(
{ ...execution.provider, version: "1" },
{
features: "selected",
},
);
const coverage = {
status: "unknown",
reason: "No call graph examined",
} as const;
const controller = new AbortController();
const service = new WasmArtifactService({
close: () => Promise.resolve(),
inspect: async () => {
if (cancel) controller.abort();
return ok({
...execution,
rawResult: { stdout: "retained command output" },
analysisProfile: profile,
relationshipCoverage: coverage,
});
},
});
expect(
await service.inspect(
const result = await service.inspect(
{ path: value.artifact.path },
{ signal: controller.signal },
),
).toMatchObject({
ok: false,
error: {
_tag: "AnalysisCancelledError",
partialObservation: { normalized_result: value },
},
});
});
);
if (cancel)
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
else expect(result.ok).toBe(true);
const evidence = result.ok ? result.value : result.error.partialObservation;
expect(evidence).toMatchObject({
normalized_result: value,
raw_result: { stdout: "retained command output" },
analysis_profile: profile,
relationship_coverage: coverage,
});
},
);
it("preserves malformed-output failure when cancellation arrives with it", async () => {
const value = wasmArtifactFixture();
+11 -13
View File
@@ -1,11 +1,14 @@
import type { ExecutionOptions } from "../AnalysisProvider.js";
import {
createAnalysisExecutionEvidence,
type ExecutionOptions,
} from "../AnalysisProvider.js";
import type { WasmArtifactPort } from "./WasmArtifactPort.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import { createEvidence, type Evidence } from "../../domain/evidence.js";
import type { Evidence } from "../../domain/evidence.js";
import {
inspectWasmArtifactInputSchema,
wasmArtifactSchema,
@@ -69,17 +72,12 @@ export class WasmArtifactService {
),
);
return completeProviderEvidence(
createEvidence(execution.subject, execution.provider, {
operation: OPERATION,
parameters: input.data,
result: report.data,
...(execution.rawResult === null
? {}
: { rawResult: execution.rawResult }),
confidence: "observed",
locations: execution.locations,
limitations: execution.limitations,
}),
createAnalysisExecutionEvidence(
OPERATION,
input.data,
{ ...execution, result: report.data },
"observed",
),
options?.signal,
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type WebBundleAnalysis,
} from "../domain/webBundleAnalysis.js";
import { buildCapturedWebBundleAnalysis } from "../domain/webBundleAnalyzerResult.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import { isIncludedScript } from "../domain/webBundleAnalyzerState.js";
import type { WebBundleScriptAnalysis } from "../domain/webBundleScriptAnalysis.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
+1 -1
View File
@@ -3,7 +3,7 @@ import { access } from "node:fs/promises";
import { expect, it } from "vitest";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
AnalysisCancelledError,
AnalysisOutputError,
+1 -1
View File
@@ -31,7 +31,7 @@ import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { jsonParts, bufferedJsonParts } from "../domain/jsonSerialization.js";
import { javaScriptRuntime } from "../domain/runtimeVersion.js";
import { err, ok, type Result } from "../domain/result.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
@@ -18,7 +18,7 @@ vi.mock("node:v8", async (importOriginal) => {
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import { WebBundleAnalysisWorker } from "./WebBundleAnalysisWorker.js";
it("refuses before spawning or reading source when no host result capacity remains", async () => {
@@ -47,7 +47,7 @@ vi.mock("node:fs/promises", async (importOriginal) => {
});
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
import {
@@ -8,7 +8,7 @@ import { createInterface } from "node:readline";
import { expect, it } from "vitest";
import { createWebTextArtifact } from "../domain/webContentArtifact.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleWorkerMessageSchema,
type WebBundleWorkerMessage,
+1 -1
View File
@@ -7,7 +7,7 @@ import { withArtifactFileHandle } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { jsonParts } from "../domain/jsonSerialization.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerInspection.js";
import type { IncludedScript } from "../domain/webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
@@ -77,14 +77,13 @@ const reconcileEntity = (
reason: "runtime-location-unresolved",
candidates: [],
});
const locations = candidatesWithLocation(entity, index, layers);
const { locations, mapped } = locateEntity(entity, index, layers);
if (entity.sourceSha256 !== null)
return reconcileCapturedSource(
entity,
entity.sourceSha256,
return reconcileCapturedSource(entity, entity.sourceSha256, {
locations,
mapped,
index,
);
});
const location = locations.at(0);
if (locations.length === 1 && location !== undefined) {
@@ -103,20 +102,40 @@ const reconcileEntity = (
reason: "ambiguous-static-candidates",
candidates: locations.map(({ candidate }) => candidate),
});
const mapped = layers.some(
(layer) => mappedRuntimePaths(layer, entity).length > 0,
);
return unresolved(entity, {
status: mapped ? "unmatched" : "unknown",
basis: "none",
confidence: "unknown",
reason: mapped
? "no-static-content-match"
: "no-authorized-location-mapping",
candidates: [],
});
return withoutStaticCandidate(entity, mapped, "unknown");
};
/**
* Classify an entity with no qualifying static candidate. Absence is claimed
* only beneath an automatic or caller-declared mapping; without one, the
* runtime location has no authorized static counterpart and stays unknown.
* Captured bytes compared against every static digest support a high-confidence
* absence; a location-only absence does not.
*/
const withoutStaticCandidate = (
entity: RuntimeReconciliationEntity,
mapped: boolean,
absenceConfidence: JavaScriptRuntimeReconciliationItem["confidence"],
): EvaluatedMatch =>
unresolved(
entity,
mapped
? {
status: "unmatched",
basis: "none",
confidence: absenceConfidence,
reason: "no-static-content-match",
candidates: [],
}
: {
status: "unknown",
basis: "none",
confidence: "unknown",
reason: "no-authorized-location-mapping",
candidates: [],
},
);
interface LocatedCandidate {
readonly candidate: StaticRuntimeCandidate;
readonly mapping: MappedRuntimePath;
@@ -125,8 +144,15 @@ interface LocatedCandidate {
const reconcileCapturedSource = (
entity: RuntimeReconciliationEntity,
sourceSha256: string,
locations: readonly LocatedCandidate[],
index: CandidateIndex,
{
locations,
mapped,
index,
}: {
readonly locations: readonly LocatedCandidate[];
readonly mapped: boolean;
readonly index: CandidateIndex;
},
): EvaluatedMatch => {
// A known mapped candidate owns the comparison. Unrelated equal bytes cannot
// repair a disagreement or establish the content of a candidate without a digest.
@@ -176,23 +202,19 @@ const reconcileCapturedSource = (
candidates: locations.map(({ candidate: located }) => located),
});
}
return unresolved(entity, {
status: "unmatched",
basis: "none",
confidence: "high",
reason: "no-static-content-match",
candidates: [],
});
return withoutStaticCandidate(entity, mapped, "high");
};
const candidatesWithLocation = (
const locateEntity = (
entity: RuntimeReconciliationEntity,
index: CandidateIndex,
layers: readonly ParsedStaticLayer[],
): LocatedCandidate[] => {
): { readonly locations: LocatedCandidate[]; readonly mapped: boolean } => {
const located = new Map<string, LocatedCandidate>();
let mapped = false;
for (const layer of layers)
for (const mapping of mappedRuntimePaths(layer, entity))
for (const mapping of mappedRuntimePaths(layer, entity)) {
mapped = true;
for (const candidate of [
...(index.byLayerCategoryPath.get(
layerCategoryPathKey(layer.layerId, entity.staticKind, mapping.path),
@@ -215,12 +237,16 @@ const candidatesWithLocation = (
const key = candidateKey(candidate);
if (!located.has(key)) located.set(key, { candidate, mapping });
}
return [...located.values()].sort((left, right) =>
compareUnicodeCodePoints(
candidateKey(left.candidate),
candidateKey(right.candidate),
}
return {
locations: [...located.values()].sort((left, right) =>
compareUnicodeCodePoints(
candidateKey(left.candidate),
candidateKey(right.candidate),
),
),
);
mapped,
};
};
const indexCandidates = (
+5 -12
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { evidenceSchema, type Evidence } from "../evidence.js";
@@ -20,7 +17,7 @@ import {
type ManagedMemberInspection,
type ManagedNativeBoundaryInspection,
} from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
assessManagedGraphOmissions,
managedGraphEvidenceCoverage,
@@ -120,13 +117,6 @@ export interface GraphBuildState {
readonly fieldNodes: Map<string, ApplicationNode>;
}
const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed application graph canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
/** Project authenticated managed Evidence records into the application graph. */
export const projectManagedApplicationGraph = (
input: ProjectManagedApplicationGraphInput,
@@ -194,7 +184,10 @@ export const projectManagedApplicationGraph = (
};
return managedApplicationGraphResultSchema.parse({
...withoutId,
projection_id: `magp_${sha256(withoutId)}`,
projection_id: `magp_${digestCanonicalValue(
withoutId,
"Managed application graph",
)}`,
});
};
+33 -32
View File
@@ -1,7 +1,8 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
/** Build-local ECMA-335 metadata token emitted as an eight-digit lowercase hex value. */
export const managedMetadataTokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const offsetSchema = z.number().int().min(0);
/** A 16-byte CLI metadata GUID without RFC 4122 version/variant restrictions. */
@@ -26,7 +27,7 @@ const assemblyIdentitySchema = z.object({
flags: z.number().int().min(0).max(0xffff_ffff),
hash_algorithm: z.number().int().min(0).max(0xffff_ffff),
public_key: publicKeyIdentitySchema,
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -36,7 +37,7 @@ const moduleIdentitySchema = z.object({
mvid: cliMetadataGuidSchema.nullable(),
enc_id: cliMetadataGuidSchema.nullable(),
enc_base_id: cliMetadataGuidSchema.nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -70,7 +71,7 @@ const assemblyReferenceSchema = z.object({
public_key_or_token: publicKeyIdentitySchema,
hash_value_sha256: digestSchema.nullable(),
hash_value_length: z.number().int().min(0),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -78,23 +79,23 @@ const managedResourceSchema = z.object({
name: z.string(),
flags: z.number().int().min(0).max(0xffff_ffff),
visibility: z.enum(["public", "private", "unknown"]),
implementation_token: tokenSchema.nullable(),
implementation_token: managedMetadataTokenSchema.nullable(),
embedded: z.boolean(),
declared_offset: z.number().int().min(0).max(0xffff_ffff),
data_length: z.number().int().min(0).nullable(),
data_sha256: digestSchema.nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
const customAttributeSchema = z.object({
parent_token: tokenSchema,
constructor_token: tokenSchema.nullable(),
parent_token: managedMetadataTokenSchema,
constructor_token: managedMetadataTokenSchema.nullable(),
type_name: z.string().nullable(),
value_length: z.number().int().min(0),
value_sha256: digestSchema,
decoded_fixed_string: z.string().nullable(),
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
});
@@ -120,7 +121,7 @@ const managedSignatureSchema = z.object({
});
const managedTypeSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
namespace: z.string(),
name: z.string(),
@@ -132,8 +133,8 @@ const managedTypeSchema = z.object({
"Framed nominal namespace/name/enclosing-name digest, independent of build-local rows. Null for historical Evidence or unresolved nesting; defining assemblies and CLR runtime identity are not established.",
),
flags: z.number().int().min(0).max(0xffff_ffff),
extends_token: tokenSchema.nullable(),
enclosing_type_token: tokenSchema
extends_token: managedMetadataTokenSchema.nullable(),
enclosing_type_token: managedMetadataTokenSchema
.nullable()
.default(null)
.describe(
@@ -153,20 +154,20 @@ const managedTypeSchema = z.object({
/** One ExportedType row: a type this assembly exports from another module or forwards. */
const managedExportedTypeSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
namespace: z.string(),
name: z.string(),
flags: z.number().int().min(0).max(0xffff_ffff),
forwarder: z.boolean(),
type_def_id: z.number().int().min(0).max(0xffff_ffff),
implementation_token: tokenSchema.nullable(),
implementation_token: managedMetadataTokenSchema.nullable(),
});
const managedFieldSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
declaring_type_token: tokenSchema.nullable(),
declaring_type_token: managedMetadataTokenSchema.nullable(),
declaring_type: z.string().nullable(),
name: z.string(),
flags: z.number().int().min(0).max(0xffff),
@@ -179,7 +180,7 @@ const managedExceptionRegionSchema = z.object({
try_length: z.number().int().min(0),
handler_offset: z.number().int().min(0),
handler_length: z.number().int().min(0),
class_token: tokenSchema.nullable(),
class_token: managedMetadataTokenSchema.nullable(),
filter_offset: z.number().int().min(0).nullable(),
});
@@ -210,7 +211,7 @@ const managedMethodBodySchema = z.object({
file_offset: offsetSchema.nullable(),
max_stack: z.number().int().min(0).nullable(),
init_locals: z.boolean().nullable(),
local_var_sig_token: tokenSchema.nullable(),
local_var_sig_token: managedMetadataTokenSchema.nullable(),
il_size: z.number().int().min(0),
il_sha256: digestSchema
.nullable()
@@ -232,9 +233,9 @@ const managedMethodBodySchema = z.object({
});
const managedMethodSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
declaring_type_token: tokenSchema.nullable(),
declaring_type_token: managedMetadataTokenSchema.nullable(),
declaring_type: z.string().nullable(),
name: z.string(),
rva: z.number().int().min(0).max(0xffff_ffff),
@@ -250,9 +251,9 @@ const managedMethodSchema = z.object({
const declaringTypeNameSchema = z.string().nullable().default(null);
const managedMemberReferenceSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
parent_token: tokenSchema.nullable(),
parent_token: managedMetadataTokenSchema.nullable(),
parent_name: declaringTypeNameSchema.describe(
"Name of a TypeDef, TypeRef, or generic-instantiation TypeSpec parent; null for other parents or when unknown",
),
@@ -261,7 +262,7 @@ const managedMemberReferenceSchema = z.object({
});
const managedUserStringSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
offset: offsetSchema.describe("File offset of the #US item"),
value: z
.string()
@@ -271,21 +272,21 @@ const managedUserStringSchema = z.object({
});
const managedModuleReferenceSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
name: z.string(),
});
const managedNativeImportSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
mapping_flags: z.number().int().min(0).max(0xffff),
mapping_flags_hex: z.string().regex(/^0x[0-9a-f]{4}$/u),
member_token: tokenSchema.nullable(),
member_token: managedMetadataTokenSchema.nullable(),
member_kind: z.enum(["method", "field", "unknown"]),
member_name: z.string().nullable(),
import_name: z.string(),
import_scope_token: tokenSchema.nullable(),
import_scope_token: managedMetadataTokenSchema.nullable(),
import_scope_name: z.string().nullable(),
no_mangle: z.boolean(),
char_set: z.enum(["not-specified", "ansi", "unicode", "auto", "unknown"]),
@@ -310,7 +311,7 @@ const managedNativeImportSchema = z.object({
});
const managedNativeImplementationSchema = z.object({
token: tokenSchema,
token: managedMetadataTokenSchema,
row_offset: offsetSchema,
name: z.string(),
rva: z.number().int().min(0).max(0xffff_ffff),
@@ -335,10 +336,10 @@ const managedNativeImplementationSchema = z.object({
});
const managedCallEdgeSchema = z.object({
caller_token: tokenSchema,
caller_token: managedMetadataTokenSchema,
caller: z.string().nullable(),
opcode: z.string(),
target_token: tokenSchema,
target_token: managedMetadataTokenSchema,
target_kind: z.enum(["method-def", "member-ref", "method-spec", "unknown"]),
target_name: z
.string()
@@ -350,10 +351,10 @@ const managedCallEdgeSchema = z.object({
});
const managedFieldAccessSchema = z.object({
method_token: tokenSchema,
method_token: managedMetadataTokenSchema,
method: z.string().nullable(),
opcode: z.string(),
field_token: tokenSchema,
field_token: managedMetadataTokenSchema,
field_name: z.string().nullable(),
field_declaring_type: declaringTypeNameSchema.describe(
"Declaring type of the accessed field; null when unknown",
+14 -10
View File
@@ -1,9 +1,11 @@
import { z } from "zod";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { emptyArraySchema } from "../emptyArraySchema.js";
import { evidenceSchema } from "../evidence.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
type ManagedMemberInspection,
} from "./managedArtifact.js";
import {
@@ -17,13 +19,12 @@ import {
buildFieldItems,
buildMethodItems,
} from "./managedMemberComparisonItems.js";
import { keyMembers, sha256 } from "./managedMemberComparisonMatch.js";
import { keyMembers } from "./managedMemberComparisonMatch.js";
import { hasSignatureResolution } from "./managedMemberSignatureIdentity.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const boundedTextSchema = z.string().min(1);
/** Two authenticated managed member observations. */
@@ -47,7 +48,7 @@ const concreteMatchBasisSchema = z.enum([
"structural-method-shape",
"field-signature",
]);
const candidateTokensSchema = z.array(tokenSchema).min(1);
const candidateTokensSchema = z.array(managedMetadataTokenSchema).min(1);
const matchedComparisonSchema = z.strictObject({
status: z.literal("matched"),
basis: concreteMatchBasisSchema,
@@ -70,7 +71,7 @@ const ambiguousComparisonSchema = z.strictObject({
candidate_right_tokens: candidateTokensSchema,
});
const memberIdentitySchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
declaring_type: z.string().nullable(),
name: z.string(),
signature_sha256: digestSchema,
@@ -247,12 +248,15 @@ export const compareManagedMembers = (
});
const limitations = comparisonLimitations(left.result, right.result);
const result = {
comparison_id: `mmc_${sha256({
left: left.evidenceId,
right: right.evidenceId,
methods: [...methodItems],
fields: [...fieldItems],
})}`,
comparison_id: `mmc_${digestCanonicalValue(
{
left: left.evidenceId,
right: right.evidenceId,
methods: [...methodItems],
fields: [...fieldItems],
},
"Managed comparison",
)}`,
algorithm: {
name: "rea-managed-member-comparison" as const,
token_identity: "build-local" as const,
@@ -1,11 +1,11 @@
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { ManagedMemberInspection } from "./managedArtifact.js";
import type { ManagedMemberComparisonResult } from "./managedMemberComparison.js";
import {
type ManagedFieldMatches,
type ManagedMethodMatches,
sha256,
import type {
ManagedFieldMatches,
ManagedMethodMatches,
} from "./managedMemberComparisonMatch.js";
import { signatureIdentity } from "./managedMemberSignatureIdentity.js";
@@ -180,7 +180,7 @@ const methodOnlyItem = (
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.left.token, side: "left" })}`,
item_id: `mmc_method_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
status: limitations.length === 0 ? "removed" : "unknown",
left: methodIdentity(member.left),
right: null,
@@ -203,7 +203,7 @@ const methodOnlyItem = (
inventory: "method",
});
return {
item_id: `mmc_method_${sha256({ token: member.right.token, side: "right" })}`,
item_id: `mmc_method_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: methodIdentity(member.right),
@@ -377,7 +377,7 @@ const fieldOnlyItem = (
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.left.token, side: "left" })}`,
item_id: `mmc_field_${digestCanonicalValue({ token: member.left.token, side: "left" }, "Managed comparison")}`,
status: limitations.length === 0 ? "removed" : "unknown",
left: fieldIdentity(member.left),
right: null,
@@ -399,7 +399,7 @@ const fieldOnlyItem = (
inventory: "field",
});
return {
item_id: `mmc_field_${sha256({ token: member.right.token, side: "right" })}`,
item_id: `mmc_field_${digestCanonicalValue({ token: member.right.token, side: "right" }, "Managed comparison")}`,
status: limitations.length === 0 ? "added" : "unknown",
left: null,
right: fieldIdentity(member.right),
@@ -448,11 +448,14 @@ export const buildMethodItems = (
: observedDimensions;
const observedChange = observedDimensions.length > 0;
items.push({
item_id: `mmc_method_${sha256({
left: pair.left.item.token,
right: pair.right.item.token,
basis: pair.basis,
})}`,
item_id: `mmc_method_${digestCanonicalValue(
{
left: pair.left.item.token,
right: pair.right.item.token,
basis: pair.basis,
},
"Managed comparison",
)}`,
status: observedChange
? "changed"
: bodyUnknown || signatureLimitations.length > 0
@@ -481,11 +484,14 @@ export const buildMethodItems = (
}
for (const ambiguous of matches.ambiguous) {
items.push({
item_id: `mmc_method_${sha256({
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
basis: ambiguous.basis,
})}`,
item_id: `mmc_method_${digestCanonicalValue(
{
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
basis: ambiguous.basis,
},
"Managed comparison",
)}`,
status: "unknown",
left: null,
right: null,
@@ -551,10 +557,13 @@ export const buildFieldItems = (
signatureIdentity(pair.right.item.signature) ||
pair.left.item.flags !== pair.right.item.flags;
items.push({
item_id: `mmc_field_${sha256({
left: pair.left.item.token,
right: pair.right.item.token,
})}`,
item_id: `mmc_field_${digestCanonicalValue(
{
left: pair.left.item.token,
right: pair.right.item.token,
},
"Managed comparison",
)}`,
status: changed
? "changed"
: signatureLimitations.length > 0
@@ -575,10 +584,13 @@ export const buildFieldItems = (
}
for (const ambiguous of matches.ambiguous)
items.push({
item_id: `mmc_field_${sha256({
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
})}`,
item_id: `mmc_field_${digestCanonicalValue(
{
left: ambiguous.left.map(({ item }) => item.token),
right: ambiguous.right.map(({ item }) => item.token),
},
"Managed comparison",
)}`,
status: "unknown",
left: null,
right: null,
@@ -1,7 +1,4 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import {
managedMemberInspectionSchema,
type ManagedMemberInspection,
@@ -15,15 +12,6 @@ import {
comparableSides,
signatureIdentity,
} from "./managedMemberSignatureIdentity.js";
import type { JsonValue } from "../jsonValue.js";
export const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed comparison canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
type Method = ManagedMemberInspection["methods"][number];
type Field = ManagedMemberInspection["fields"][number];
@@ -69,49 +57,58 @@ const keyMethod = (item: Method, declared: DeclaredNameOf): Keyed<Method> => ({
item.body.status === "present" &&
item.body.il_sha256 !== null &&
item.body.normalized_il_sha256 !== null
? sha256([
"method-exact",
signatureIdentity(item.signature),
item.body.il_sha256,
item.body.normalized_il_sha256,
])
? digestCanonicalValue(
[
"method-exact",
signatureIdentity(item.signature),
item.body.il_sha256,
item.body.normalized_il_sha256,
],
"Managed comparison",
)
: null,
// The blob, with type operands named, is the exact signature; decoding
// only gates the shape keys.
signatureKey:
declared(item) === null
? null
: sha256([
"method-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
: digestCanonicalValue(
[
"method-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
],
"Managed comparison",
),
structuralKey:
item.signature.parse_status === "decoded" && item.body.status === "present"
? sha256([
"method-structural",
// Decoded type strings carry build-local rows; the identity names them.
signatureIdentity(item.signature),
item.body.status,
item.body.header_format,
item.body.il_size,
item.body.max_stack,
item.body.init_locals,
item.body.opcode_counts,
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
opcode,
operand_kind,
operand_kind === "constant" ? operand : null,
]),
item.body.exception_regions.map((region) => [
region.flags,
region.try_length,
region.handler_length,
region.class_token === null ? null : "type-token",
region.filter_offset === null ? null : "filter",
]),
])
? digestCanonicalValue(
[
"method-structural",
// Decoded type strings carry build-local rows; the identity names them.
signatureIdentity(item.signature),
item.body.status,
item.body.header_format,
item.body.il_size,
item.body.max_stack,
item.body.init_locals,
item.body.opcode_counts,
item.body.anchors.map(({ opcode, operand_kind, operand }) => [
opcode,
operand_kind,
operand_kind === "constant" ? operand : null,
]),
item.body.exception_regions.map((region) => [
region.flags,
region.try_length,
region.handler_length,
region.class_token === null ? null : "type-token",
region.filter_offset === null ? null : "filter",
]),
],
"Managed comparison",
)
: null,
});
@@ -122,25 +119,34 @@ const keyField = (item: Field, declared: DeclaredNameOf): Keyed<Field> => ({
signatureId: signatureIdentity(item.signature),
exactKey:
item.signature.parse_status === "decoded"
? sha256(["field-exact", signatureIdentity(item.signature)])
? digestCanonicalValue(
["field-exact", signatureIdentity(item.signature)],
"Managed comparison",
)
: null,
// Only an undecoded field reaches this round: a decoded field with the
// same signature already met its counterpart in the exact round.
signatureKey:
declared(item) === null
? null
: sha256([
"field-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
]),
structuralKey: sha256([
"field-structural",
item.signature.kind,
item.signature.field_type,
item.flags,
]),
: digestCanonicalValue(
[
"field-signature",
declared(item),
item.name,
signatureIdentity(item.signature),
],
"Managed comparison",
),
structuralKey: digestCanonicalValue(
[
"field-structural",
item.signature.kind,
item.signature.field_type,
item.flags,
],
"Managed comparison",
),
});
const matchMethods = (
@@ -237,7 +243,8 @@ export const identityOf = ({
}: {
readonly declaring_type: DeclaredName;
readonly name: string;
}): string => sha256([declaring_type, name]);
}): string =>
digestCanonicalValue([declaring_type, name], "Managed comparison");
/** Narrow a key to members of one declared type and name. */
const declaredAs =
@@ -246,7 +253,7 @@ const declaredAs =
const value = key(item);
return value === null || item.identityUnavailable
? null
: sha256([item.identity, value]);
: digestCanonicalValue([item.identity, value], "Managed comparison");
};
const matchByKeys = <Item extends Declared>({
@@ -1,14 +1,10 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { digestCanonicalValue } from "../canonicalDigest.js";
import type { Evidence } from "../evidence.js";
import {
managedNativeBoundaryInspectionSchema,
type ManagedNativeBoundaryInspection,
} from "./managedArtifact.js";
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
import type { JsonValue } from "../jsonValue.js";
import {
managedNativeVerificationResultSchema,
type ManagedNativeVerificationInput,
@@ -20,13 +16,6 @@ import {
verifyPinvoke,
} from "./managedNativeVerificationMatch.js";
const sha256 = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed/native verification canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
const parseManagedBoundary = (
input: ManagedNativeVerificationInput,
): {
@@ -65,6 +54,9 @@ export const verifyManagedNativeBoundaries = (
});
return managedNativeVerificationResultSchema.parse({
...withoutId,
verification_id: `mnv_${sha256(withoutId)}`,
verification_id: `mnv_${digestCanonicalValue(
withoutId,
"Managed/native verification",
)}`,
});
};
@@ -1,13 +1,10 @@
import { createHash } from "node:crypto";
import { basename } from "node:path";
import canonicalize from "canonicalize";
import { parseEvidence } from "../evidence.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { functionDossierSchema } from "../hopperValues.js";
import { inspectMachoSchema } from "../native/nativeInspection.js";
import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import {
pinvokeVerificationSchema,
type ManagedNativeVerificationInput,
@@ -16,13 +13,6 @@ import {
type PinvokeVerification,
} from "./managedNativeVerificationSchemas.js";
const digest = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed/native verification canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
type PinvokeImport = ManagedNativeBoundaryInspection["pinvoke_imports"][number];
type VerifiedPinvoke = {
readonly verification: PinvokeVerification;
@@ -326,13 +316,16 @@ export const verifyPinvoke = ({
const limitations = pinvokeLimitations(selection.match, item);
return {
verification: pinvokeVerificationSchema.parse({
item_id: `mnv_pinvoke_${digest({
managedEvidenceId,
token: item.token,
importName: item.import_name,
importScope: item.import_scope_name,
candidates: selection.match.candidates,
})}`,
item_id: `mnv_pinvoke_${digestCanonicalValue(
{
managedEvidenceId,
token: item.token,
importName: item.import_name,
importScope: item.import_scope_name,
candidates: selection.match.candidates,
},
"Managed/native verification",
)}`,
managed,
...selection.match,
evidence_links: [
@@ -2,12 +2,14 @@ import { z } from "zod";
import { emptyArraySchema } from "../emptyArraySchema.js";
import { evidenceSchema } from "../evidence.js";
import { cliMetadataGuidSchema } from "./managedArtifact.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
} from "./managedArtifact.js";
import { digestSchema } from "../digests.js";
import { prefixedDigestSchema } from "../digests.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const textSchema = z.string().min(1);
const nativeSymbolSchema = z.strictObject({
@@ -25,8 +27,8 @@ export type NativeSymbol = z.infer<typeof nativeSymbolSchema>;
const pinvokeVerificationContextShape = {
item_id: prefixedDigestSchema("mnv_pinvoke"),
managed: z.strictObject({
token: tokenSchema,
member_token: tokenSchema.nullable(),
token: managedMetadataTokenSchema,
member_token: managedMetadataTokenSchema.nullable(),
member_name: z.string().nullable(),
import_name: z.string().min(1),
import_scope_name: z.string().nullable(),
+12 -14
View File
@@ -1,20 +1,19 @@
import { createHash } from "node:crypto";
import canonicalize from "canonicalize";
import { z } from "zod";
import { evidenceSchema, type Evidence } from "../evidence.js";
import {
cliMetadataGuidSchema,
managedMetadataTokenSchema,
managedMemberInspectionSchema,
type ManagedMemberInspection,
} from "./managedArtifact.js";
import type { JsonValue } from "../jsonValue.js";
import { digestCanonicalValue } from "../canonicalDigest.js";
import { digestSchema, prefixedDigestSchema } from "../digests.js";
import { parseManagedInspectionEvidence } from "./managedInspectionEvidence.js";
const evidenceIdSchema = prefixedDigestSchema("ev");
const tokenSchema = z.string().regex(/^0x[0-9a-f]{8}$/u);
const textSchema = z.string().min(1);
const decompilerSchema = z.strictObject({
@@ -26,7 +25,7 @@ const decompilerSchema = z.strictObject({
});
const managedReconstructionMethodInputSchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
signature_sha256: digestSchema,
normalized_il_sha256: digestSchema.nullable(),
reconstruction: z.strictObject({
@@ -52,7 +51,7 @@ export const managedReconstructionImportInputSchema = z.strictObject({
});
const importedMethodSchema = z.strictObject({
token: tokenSchema,
token: managedMetadataTokenSchema,
declaring_type: z.string().nullable(),
name: z.string(),
signature_sha256: digestSchema,
@@ -122,13 +121,6 @@ type StaticObservation =
const sha256Text = (value: string): string =>
createHash("sha256").update(value).digest("hex");
const sha256Json = (value: JsonValue): string => {
const serialized = canonicalize(value);
if (serialized === undefined)
throw new TypeError("Managed reconstruction canonicalization failed");
return createHash("sha256").update(serialized).digest("hex");
};
const countLines = (text: string): number =>
text.length === 0 ? 0 : text.split(/\r\n|\r|\n/u).length;
@@ -227,7 +219,10 @@ const buildDecompilerRecord = (
decompiler: ManagedReconstructionImportInput["decompiler"],
): ManagedReconstructionImportResult["decompiler"] => ({
...decompiler,
options_sha256: sha256Json(decompiler.options),
options_sha256: digestCanonicalValue(
decompiler.options,
"Managed reconstruction",
),
});
const RECONSTRUCTION_LIMITATIONS = [
@@ -282,6 +277,9 @@ export const importManagedReconstruction = (
);
return managedReconstructionImportResultSchema.parse({
...withoutId,
reconstruction_id: `mre_${sha256Json(withoutId)}`,
reconstruction_id: `mre_${digestCanonicalValue(
withoutId,
"Managed reconstruction",
)}`,
});
};
+1 -1
View File
@@ -8,7 +8,7 @@ import {
} from "./webBundleAnalyzer.js";
import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js";
import { webBundleAnalysisSchema } from "./webBundleAnalysis.js";
import { isIncludedScript } from "./webBundleAnalyzerInspection.js";
import { isIncludedScript } from "./webBundleAnalyzerState.js";
import { webBundleScriptAnalysisSchema } from "./webBundleScriptAnalysis.js";
import { createWebTextArtifact } from "./webContentArtifact.js";
+4 -4
View File
@@ -1,10 +1,10 @@
import type { WebPageInspection } from "./browserObservationSchemas.js";
import { analyzeScript } from "./webBundleAnalyzerInspection.js";
import {
analyzeScript,
emptyAccumulator,
createWebBundleAnalysisAccumulator,
isIncludedScript,
type IncludedScript,
} from "./webBundleAnalyzerInspection.js";
} from "./webBundleAnalyzerState.js";
import { buildCapturedWebBundleAnalysis } from "./webBundleAnalyzerResult.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import {
@@ -16,7 +16,7 @@ import {
export const analyzeCapturedWebScript = (
script: IncludedScript,
): WebBundleScriptAnalysis => {
const accumulator = emptyAccumulator();
const accumulator = createWebBundleAnalysisAccumulator();
analyzeScript(script, accumulator);
const failed = accumulator.failedScripts.length > 0;
const recovered = accumulator.recoveredScripts.length > 0;
+4 -39
View File
@@ -1,8 +1,11 @@
import type {
AnalysisAccumulator,
IncludedScript,
} from "./webBundleAnalyzerState.js";
import * as t from "@babel/types";
import { sanitizeEndpointCandidate } from "./browserObservation.js";
import { compositeKey } from "./unicodeCodePointOrder.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
import { indexJavaScriptBindingsWithReceiversSteps } from "./javascript/javascriptSemanticAnalysis.js";
@@ -37,26 +40,6 @@ type ChunkEdge = BundleObservations["chunks"]["edges"][number];
type Finding = BundleObservations["routes"][number];
type WebMcpDeclaration = BundleObservations["webmcp_declarations"][number];
type Inference = WebBundleAnalysis["inferences"][number];
type BrowserScript = WebPageInspection["scripts"]["items"][number];
type IncludedSource = Extract<BrowserScript["source"], { included: true }>;
export type IncludedScript = Omit<BrowserScript, "source"> & {
readonly source: IncludedSource;
};
export interface AnalysisAccumulator {
readonly edges: ChunkEdge[];
readonly routes: Finding[];
readonly endpoints: Finding[];
readonly webMcp: WebMcpDeclaration[];
readonly inferences: Inference[];
readonly seen: Set<string>;
visitedNodes: number;
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
readonly widenedScripts: string[];
}
export const analyzeScript = (
script: IncludedScript,
accumulator: AnalysisAccumulator,
@@ -501,24 +484,6 @@ const basis = (script: IncludedScript, detector: string) => ({
detector,
});
export const emptyAccumulator = (): AnalysisAccumulator => ({
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
});
export const isIncludedScript = (
script: BrowserScript,
): script is IncludedScript => script.source.included;
const routeCallNames = [
"route",
"addRoute",
+8 -20
View File
@@ -4,10 +4,12 @@ import {
webBundleAnalysisSchema,
type WebBundleAnalysis,
} from "./webBundleAnalysis.js";
import type {
AnalysisAccumulator,
IncludedScript,
} from "./webBundleAnalyzerInspection.js";
import {
createWebBundleAnalysisAccumulator,
isIncludedScript,
type AnalysisAccumulator,
type IncludedScript,
} from "./webBundleAnalyzerState.js";
import {
webBundleScriptAnalysisSchema,
type WebBundleScriptAnalysis,
@@ -29,9 +31,7 @@ export const buildCapturedWebBundleAnalysis = (
);
byKey.set(result.script_key, result);
}
const sourceScripts = inspection.scripts.items.filter(
(script): script is IncludedScript => script.source.included,
);
const sourceScripts = inspection.scripts.items.filter(isIncludedScript);
const expectedKeys = new Set(
sourceScripts.map(({ script_key }) => script_key),
);
@@ -145,19 +145,7 @@ const mergeScriptResults = (
{ readonly status: "analyzed" }
>[],
): AnalysisAccumulator => {
const accumulator: AnalysisAccumulator = {
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
};
const accumulator = createWebBundleAnalysisAccumulator();
for (const result of results) {
accumulator.visitedNodes += result.visited_ast_nodes;
if (result.parse_status === "failed") {
+44
View File
@@ -0,0 +1,44 @@
import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
type BundleObservations = WebBundleAnalysis["observations"];
type BrowserScript = WebPageInspection["scripts"]["items"][number];
type IncludedSource = Extract<BrowserScript["source"], { included: true }>;
/** Captured script whose source bytes are available for inert analysis. */
export type IncludedScript = Omit<BrowserScript, "source"> & {
readonly source: IncludedSource;
};
/** Mutable facts shared by per-script inspection and bundle result assembly. */
export interface AnalysisAccumulator {
readonly edges: BundleObservations["chunks"]["edges"];
readonly routes: BundleObservations["routes"];
readonly endpoints: BundleObservations["endpoints"];
readonly webMcp: BundleObservations["webmcp_declarations"];
readonly inferences: WebBundleAnalysis["inferences"];
readonly seen: Set<string>;
visitedNodes: number;
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
readonly widenedScripts: string[];
}
export const createWebBundleAnalysisAccumulator = (): AnalysisAccumulator => ({
edges: [],
routes: [],
endpoints: [],
webMcp: [],
inferences: [],
seen: new Set(),
visitedNodes: 0,
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
});
export const isIncludedScript = (
script: BrowserScript,
): script is IncludedScript => script.source.included;
+23 -26
View File
@@ -9,6 +9,7 @@ import {
type AnalysisClient,
type AnalysisClientContext,
type AnalysisOperation,
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config/types.js";
import {
@@ -478,6 +479,24 @@ export const createGhidraProviderClient = (input: {
operation,
);
};
const startSession = async (
operation: AnalysisOperation,
options?: ExecutionOptions,
): Promise<Result<GhidraSessionInfo, AnalysisError>> => {
const started = await client.start(options?.signal, options?.progress);
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
providerStarted = true;
const failed = await checkSessionObservations(operation, started.value);
return failed === undefined ? started : err(failed);
};
const releaseLimitation = unverifiedGhidraBuildLimitation(
prerequisites.value.providerVersion,
);
@@ -603,19 +622,8 @@ export const createGhidraProviderClient = (input: {
),
);
if (operation === "health") {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
const started = await startSession(operation, options);
if (!started.ok) return started;
return ok(
createAnalysisExecution(started.value, committedProfile.provider, {
analysisProfile: committedProfile,
@@ -633,19 +641,8 @@ export const createGhidraProviderClient = (input: {
seeds !== undefined ||
softFloat
) {
const started = await client.start(options?.signal, options?.progress);
if (started.ok) providerStarted = true;
if (!started.ok)
return err(
projectSessionError(
operation,
started.error,
config.ghidraStartupTimeoutMs,
lostProviderHealth(),
),
);
const failed = await checkSessionObservations(operation, started.value);
if (failed !== undefined) return err(failed);
const started = await startSession(operation, options);
if (!started.ok) return started;
}
const called = await client.callTool(operation, input.value, {
...(options?.signal === undefined ? {} : { signal: options.signal }),
+3 -8
View File
@@ -19,11 +19,6 @@ import type {
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js";
/**
* Omitted `native_command_timeout_ms`. Distinct from objdump/rizin
* `command_timeout_ms`, which is a separate 120000 ms one-shot budget.
*/
export const NATIVE_COMMAND_TIMEOUT_MS = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS;
/** Retain complete command output up to a bounded aggregate size. */
export const NATIVE_COMMAND_OUTPUT_BUDGET_BYTES = 64 * 1024 * 1024;
/**
@@ -131,7 +126,7 @@ export interface NativeCommandFailureCapture {
export interface NativeCommandOptions {
readonly signal?: AbortSignal;
readonly acceptNonZero?: boolean;
/** Selected wall clock; omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
/** Selected wall clock; omitted uses `DEFAULT_NATIVE_COMMAND_TIMEOUT_MS`. */
readonly timeoutMs?: number;
}
@@ -155,7 +150,7 @@ export type NativeToolResolver = (
tool: string,
signal?: AbortSignal,
launcher?: NativeCommandLauncher,
/** Selected command deadline. Omitted uses `NATIVE_COMMAND_TIMEOUT_MS`. */
/** Selected command deadline. Omitted uses `DEFAULT_NATIVE_COMMAND_TIMEOUT_MS`. */
timeoutMs?: number,
) => Promise<Result<ResolvedTool, NativeCommandFailure>>;
@@ -533,7 +528,7 @@ const captureProcess = async (
...(options.signal === undefined ? {} : { signal: options.signal }),
},
{
timeoutMs: options.timeoutMs ?? NATIVE_COMMAND_TIMEOUT_MS,
timeoutMs: options.timeoutMs ?? DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
diagnosticBytes: NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
acceptNonZeroExit: options.acceptNonZero === true,
},
+3 -5
View File
@@ -14,10 +14,8 @@ import type {
NativeCommandOptions,
NativeCommandRunner,
} from "./CommandRunner.js";
import {
NATIVE_COMMAND_TIMEOUT_MS,
XcrunCommandRunner,
} from "./CommandRunner.js";
import { XcrunCommandRunner } from "./CommandRunner.js";
import { DEFAULT_NATIVE_COMMAND_TIMEOUT_MS } from "../domain/native/nativeDeadlines.js";
import { NativeMacOSProvider } from "./NativeMacOSProvider.js";
vi.mock("../process/OwnedCommand.js", async (original) => ({
@@ -59,7 +57,7 @@ describe("native_command_timeout_ms", () => {
const result = await execute(runner, {});
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(runner.timeouts.every((timeout) => timeout === 60_000)).toBe(true);
expect(NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
expect(DEFAULT_NATIVE_COMMAND_TIMEOUT_MS).toBe(60_000);
expect(runner.timeouts.length).toBeGreaterThan(0);
});
+5 -5
View File
@@ -59,6 +59,7 @@ import {
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
DEFAULT_NATIVE_UI_DEADLINE_MS,
MAX_NATIVE_TIMER_MS,
fixedNativeAllowance,
@@ -95,7 +96,6 @@ import {
} from "../domain/native/nativeInspection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
NATIVE_COMMAND_TIMEOUT_MS,
NATIVE_COMMAND_OUTPUT_BUDGET_BYTES,
NATIVE_FAILURE_DIAGNOSTIC_BYTES,
NativeCommandFailure,
@@ -603,7 +603,7 @@ class NativeMacOSClient implements AnalysisClient {
): Promise<Result<NativeObservation, AnalysisError>> {
const timeout = readNativeDeadline(
parameters.native_command_timeout_ms,
NATIVE_COMMAND_TIMEOUT_MS,
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
);
if (!timeout.ok)
return Promise.resolve(
@@ -1295,7 +1295,7 @@ const isNativeOperation = (
const translateCommandFailure = (
operation: NativeToolName,
failure: NativeCommandFailure,
timeoutMs = NATIVE_COMMAND_TIMEOUT_MS,
timeoutMs = DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
): AnalysisError => {
const cleanupOwners = [
...failure.cleanupFailures,
@@ -1367,7 +1367,7 @@ const translateCommandFailure = (
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
@@ -1429,7 +1429,7 @@ const translateCommandFailure = (
timeoutMs,
setting: "native_command_timeout_ms",
cliFlag: "--native-command-timeout-ms",
defaultMs: NATIVE_COMMAND_TIMEOUT_MS,
defaultMs: DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
phase: "macOS native command deadline",
unchanged: `The ${String(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES)}-byte command output allowance and the ${String(NATIVE_FAILURE_DIAGNOSTIC_BYTES)}-byte failure-diagnostic excerpt have no runtime override.`,
}),
+2 -2
View File
@@ -1,7 +1,7 @@
import { expect, it, vi } from "vitest";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { createNativeUiHelperRuntime } from "./NativeUiHelperRuntime.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
import { DEFAULT_NATIVE_UI_DEADLINE_MS } from "../domain/native/nativeDeadlines.js";
vi.mock("../process/ExecFileOutput.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../process/ExecFileOutput.js")>()),
@@ -37,7 +37,7 @@ it.each([250_000, undefined])(
} finally {
await runtime.close();
}
const expected = deadlineMs ?? NATIVE_UI_DEADLINE_MS;
const expected = deadlineMs ?? DEFAULT_NATIVE_UI_DEADLINE_MS;
const calls = vi.mocked(execFileOutput).mock.calls;
expect(calls).toHaveLength(2);
expect(calls[0]?.[0]).toBe("/usr/bin/xcrun");
+2 -2
View File
@@ -8,9 +8,9 @@ import { fileURLToPath } from "node:url";
import { SWIFTC_INTERRUPT } from "../process/DarwinProcessRunTokenReader.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import { safeParseJson } from "../domain/safeJson.js";
import { DEFAULT_NATIVE_UI_DEADLINE_MS } from "../domain/native/nativeDeadlines.js";
import type { NativeUiHelper } from "./NativeUiObservation.js";
import { NATIVE_UI_HELPER_MAX_BUFFER } from "./NativeUiOutputBudget.js";
import { NATIVE_UI_DEADLINE_MS } from "./NativeUiInterruptionControl.js";
export interface NativeUiHelperRuntime {
readonly invoke: NativeUiHelper;
@@ -25,7 +25,7 @@ export interface NativeUiHelperRuntime {
/** Lazily compile one owned helper per observation/scenario and remove its compiler cache. */
export const createNativeUiHelperRuntime = (
environment: Readonly<NodeJS.ProcessEnv>,
deadlineMs = NATIVE_UI_DEADLINE_MS,
deadlineMs = DEFAULT_NATIVE_UI_DEADLINE_MS,
): NativeUiHelperRuntime => {
const selectedEnvironment = snapshotEnvironment(environment);
const deadlineSignal = AbortSignal.timeout(deadlineMs);
+2 -5
View File
@@ -8,9 +8,6 @@ import {
nativeDeadlineRecovery,
} from "../domain/native/nativeDeadlines.js";
/** Omitted `native_ui_deadline_ms`. */
export const NATIVE_UI_DEADLINE_MS = DEFAULT_NATIVE_UI_DEADLINE_MS;
type Interruption =
| { readonly kind: "caller"; readonly reason: unknown }
| { readonly kind: "deadline"; readonly reason: unknown };
@@ -38,7 +35,7 @@ export class NativeUiInterruptionControl {
constructor(
caller: AbortSignal | undefined,
deadline: AbortSignal,
deadlineMs = NATIVE_UI_DEADLINE_MS,
deadlineMs = DEFAULT_NATIVE_UI_DEADLINE_MS,
) {
this.#caller = caller;
this.#deadline = deadline;
@@ -86,7 +83,7 @@ export class NativeUiInterruptionControl {
timeoutMs: this.#deadlineMs,
setting: "native_ui_deadline_ms",
cliFlag: "--native-ui-deadline-ms",
defaultMs: NATIVE_UI_DEADLINE_MS,
defaultMs: DEFAULT_NATIVE_UI_DEADLINE_MS,
phase: "native UI observation deadline",
detail: " A partial observation may already be retained.",
unchanged:
+2 -5
View File
@@ -34,10 +34,7 @@ import {
NATIVE_UI_OUTPUT_BUDGET_BYTES,
NATIVE_UI_OUTPUT_WEIGHT,
} from "./NativeUiOutputBudget.js";
import {
NATIVE_UI_DEADLINE_MS,
NativeUiInterruptionControl,
} from "./NativeUiInterruptionControl.js";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
const helper = fileURLToPath(
new URL("../../bridge/native/ReaNativeUI.swift", import.meta.url),
@@ -79,7 +76,7 @@ export const observeNativeUi = async (
new NativeUiInterruptionControl(
options.signal,
runtime.signal,
runtime.deadlineMs ?? NATIVE_UI_DEADLINE_MS,
runtime.deadlineMs ?? DEFAULT_NATIVE_UI_DEADLINE_MS,
);
const ownsInterruption = options.interruption === undefined;
let result: Result<z.infer<typeof nativeUiResultSchema>, AnalysisError>;
@@ -0,0 +1,40 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { expect, it } from "vitest";
it("loads the isolated coordinator without loading its worker's JavaScript parser", async () => {
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--input-type=module",
"-e",
`
import { registerHooks } from "node:module";
const parser = import.meta.resolve("@babel/parser");
let parserLoaded = false;
const hook = registerHooks({
load(url, context, nextLoad) {
if (url === parser) parserLoaded = true;
return nextLoad(url, context);
},
});
const { IsolatedWebBundleAnalyzer } = await import(process.argv[1]);
hook.deregister();
process.stdout.write(JSON.stringify({
coordinatorLoaded: typeof IsolatedWebBundleAnalyzer === "function",
parserLoaded,
}));
`,
new URL(
"../../../dist/browser/IsolatedWebBundleAnalyzer.js",
import.meta.url,
).href,
],
{ timeout: 10_000 },
);
expect(JSON.parse(stdout)).toEqual({
coordinatorLoaded: true,
parserLoaded: false,
});
});
@@ -229,3 +229,204 @@ it("keeps unavailable mapped digests unknown instead of borrowing unrelated matc
reason: "static-or-runtime-coverage-incomplete",
});
});
const UNRELATED = "export const unrelated = 17;\n";
type LayerSetup = {
readonly root: string;
readonly mapping: "none" | "explicit";
};
/** Reconcile one captured script, located at absent.js under live, against layers. */
const reconcileCapturedScript = async (input: {
readonly layers: readonly LayerSetup[];
readonly live: string;
readonly source: string;
readonly includeWorker?: boolean;
}) => {
const result = reconcileJavaScriptRuntime(
parseRuntimeReconciliationInput(
reconcileJavaScriptRuntimeInputSchema.parse({
static_layers: await Promise.all(
input.layers.map(async ({ root, mapping }, index) => ({
role: (["application", "cache", "assets"] as const)[index],
analysis: await analyzeFixture(root),
runtime_mappings:
mapping === "explicit"
? [{ kind: "file-root", root: input.live, artifact_prefix: "" }]
: [],
})),
),
runtime_observations: [
electronRuntimeEvidence(input.live, input.source, {
scriptFile: "absent.js",
includeWorker: input.includeWorker ?? false,
}),
],
}),
),
);
const item = (kind: string) =>
result.reconciliations.find(({ entity_kind }) => entity_kind === kind);
return { result, script: item("script"), worker: item("worker") };
};
it("keeps an unmapped captured script without a content match unknown", async () => {
const root = await applicationFixture();
const live = await createTestTempDirectory("rea-runtime-unmapped-");
const { result, script, worker } = await reconcileCapturedScript({
layers: [{ root, mapping: "none" }],
live,
source: UNRELATED,
includeWorker: true,
});
const unknown = {
status: "unknown",
basis: "none",
confidence: "unknown",
reason: "no-authorized-location-mapping",
static_layer_id: null,
static_node_id: null,
candidate_static_count: 0,
};
expect(script).toMatchObject(unknown);
// A source-free worker at an unmapped location shares the same rule.
expect(worker).toMatchObject(unknown);
expect(result.summary).toMatchObject({ matched: 0, unmatched: 0 });
expect(
result.graph.edges.some(({ relation }) => relation === "observed_as"),
).toBe(false);
});
it.each(["explicit", "implicit-root"] as const)(
"reports a mapped captured script without a content match as unmatched (%s)",
async (mapping) => {
const root = await applicationFixture();
const live =
mapping === "explicit"
? await createTestTempDirectory("rea-runtime-mapped-absent-")
: root;
const { result, script } = await reconcileCapturedScript({
layers: [{ root, mapping: mapping === "explicit" ? "explicit" : "none" }],
live,
source: UNRELATED,
});
expect(script).toMatchObject({
status: "unmatched",
basis: "none",
confidence: "high",
reason: "no-static-content-match",
candidate_static_count: 0,
});
expect(result.summary.unmatched).toBe(1);
},
);
it.each([
["only the second", ["none", "explicit"], "unmatched"],
["only the first", ["explicit", "none"], "unmatched"],
["neither", ["none", "none"], "unknown"],
] as const)(
"classifies an unmatched captured script when %s of two layers maps it",
async (_label, mappings, status) => {
const live = await createTestTempDirectory("rea-runtime-layer-mapping-");
const { script } = await reconcileCapturedScript({
layers: await Promise.all(
mappings.map(async (mapping) => ({
root: await applicationFixture(),
mapping,
})),
),
live,
source: UNRELATED,
});
expect(script).toMatchObject({
status,
reason:
status === "unknown"
? "no-authorized-location-mapping"
: "no-static-content-match",
});
},
);
it.each([
[1, "matched", "unique-content-match"],
[2, "ambiguous", "ambiguous-static-candidates"],
] as const)(
"keeps unmapped content-only correspondence with %i digest candidates",
async (copies, status, reason) => {
const live = await createTestTempDirectory("rea-runtime-unmapped-content-");
const { result, script } = await reconcileCapturedScript({
layers: await Promise.all(
Array.from({ length: copies }, async () => {
const root = await applicationFixture();
await writeFile(join(root, "other.js"), CAPTURED);
return { root, mapping: "none" as const };
}),
),
live,
source: CAPTURED,
});
expect(script).toMatchObject({
status,
basis: copies === 1 ? "content-sha256" : "none",
reason,
candidate_static_count: copies,
});
expect(result.summary.unmatched).toBe(0);
},
);
it.each([
["https://example.test/assets/absent.js", "unmatched"],
["https://other.test/assets/absent.js", "unknown"],
] as const)(
"applies URL-prefix mapping presence to captured script %s",
async (url, status) => {
const root = await applicationFixture();
const parsed = parseRuntimeReconciliationInput(
reconcileJavaScriptRuntimeInputSchema.parse({
static_layers: [
{
role: "application",
analysis: await analyzeFixture(root),
runtime_mappings: [
{
kind: "url-prefix",
prefix: "https://example.test/assets/",
artifact_prefix: "",
},
],
},
],
runtime_observations: [
electronRuntimeEvidence(root, UNRELATED, { includeWorker: false }),
],
}),
);
// Model a URL-located capture at the matcher boundary; browser and
// Inspector projections supply URL locations to the same classifier.
const entities = projectRuntimeCaptures(parsed.captures).entities.map(
(entity) =>
entity.kind === "script"
? { ...entity, location: { kind: "url" as const, value: url } }
: entity,
);
const result = reconcileRuntimeEntities({
entities,
candidates: collectStaticRuntimeCandidates(parsed.layers),
layers: parsed.layers,
});
expect(
result.items.find(({ entity_kind }) => entity_kind === "script"),
).toMatchObject({
status,
confidence: status === "unknown" ? "unknown" : "high",
reason:
status === "unknown"
? "no-authorized-location-mapping"
: "no-static-content-match",
});
},
);