fix(javascript): honor cancellation before evidence publication (#1098)
This commit is contained in:
parent
f8e143a3bc
commit
cc0ee2edc6
|
|
@ -81,6 +81,15 @@ uses `cleanup_incomplete` and lists only the owned resource kinds that remain.
|
|||
Derived comparisons and reconstruction verification yield before computation
|
||||
and before publication, so cancellation cannot race with successful Evidence.
|
||||
|
||||
`analyze_javascript_application` also yields between reconstruction phases and
|
||||
during complete-result sealing, Evidence JSON validation, and canonical hashing.
|
||||
Cancellation observed before completion returns `cancelled` and prevents the
|
||||
provisional result from entering the session ledger; prior Evidence stays usable.
|
||||
Single-file parsing, graph completion, and full application-result schema
|
||||
validation still run synchronously, so control messages can wait for those
|
||||
phases to release the event loop. A rejected client promise alone does not
|
||||
establish that the server has stopped its work.
|
||||
|
||||
CLI calls work without a progress token and translate SIGINT into the same
|
||||
AbortSignal used by providers. Existing controlled-process cleanup and provider
|
||||
shutdown rules still apply; REA never kills a process it cannot prove it owns.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { setImmediate } from "node:timers/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js";
|
||||
|
||||
describe("JavaScript cooperative computation lifecycle", () => {
|
||||
it("observes scheduler cancellation and closes abandoned work", async () => {
|
||||
const controller = new AbortController();
|
||||
let released = false;
|
||||
function* work(): Generator<void, number> {
|
||||
try {
|
||||
for (;;) yield;
|
||||
} finally {
|
||||
released = true;
|
||||
}
|
||||
}
|
||||
const cancellation = setImmediate().then(() => controller.abort());
|
||||
await expect(
|
||||
completeJavaScriptAnalysisSteps(work(), controller.signal),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
await cancellation;
|
||||
expect(released).toBe(true);
|
||||
});
|
||||
|
||||
it("does not start pre-cancelled work and preserves completed values", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
let started = false;
|
||||
function* work(): Generator<void, number> {
|
||||
started = true;
|
||||
yield;
|
||||
return 42;
|
||||
}
|
||||
await expect(
|
||||
completeJavaScriptAnalysisSteps(work(), controller.signal),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
expect(started).toBe(false);
|
||||
expect(await completeJavaScriptAnalysisSteps(work())).toBe(42);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { setImmediate } from "node:timers/promises";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
|
||||
/** Reject cancelled application work before computing or publishing more results. */
|
||||
export const assertJavaScriptAnalysisActive = (signal?: AbortSignal): void => {
|
||||
if (signal?.aborted === true)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"JavaScript application analysis cancelled",
|
||||
);
|
||||
};
|
||||
|
||||
/** Let request control messages run, then recheck the current analysis intent. */
|
||||
export const checkpointJavaScriptAnalysis = async (
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
assertJavaScriptAnalysisActive(signal);
|
||||
await setImmediate();
|
||||
assertJavaScriptAnalysisActive(signal);
|
||||
};
|
||||
|
||||
/** Drive bounded owned-domain steps while permitting cancellation and control I/O. */
|
||||
export const completeJavaScriptAnalysisSteps = async <Value>(
|
||||
steps: Iterator<void, Value, void>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Value> => {
|
||||
let deadline = performance.now() + 8;
|
||||
try {
|
||||
for (;;) {
|
||||
assertJavaScriptAnalysisActive(signal);
|
||||
const step = steps.next();
|
||||
if (step.done) {
|
||||
assertJavaScriptAnalysisActive(signal);
|
||||
return step.value;
|
||||
}
|
||||
if (performance.now() >= deadline) {
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
deadline = performance.now() + 8;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
steps.return?.();
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { setImmediate } from "node:timers";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
|
||||
|
||||
describe("JavaScript analysis cancellation before publication", () => {
|
||||
it.each([
|
||||
"parse_javascript_source",
|
||||
"build_javascript_application_graph",
|
||||
"build_javascript_semantic_graph",
|
||||
"validate_javascript_application_result",
|
||||
"create_javascript_application_evidence",
|
||||
"seal_javascript_application_result",
|
||||
"validate_javascript_application_evidence",
|
||||
"hash_javascript_application_evidence",
|
||||
])("rejects cancellation scheduled during %s", async (phase) => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-phase-");
|
||||
await writeFile(
|
||||
join(root, "main.js"),
|
||||
"export function inspect(value) { return JSON.stringify(value); }\n",
|
||||
);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const terminal: string[] = [];
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) terminal.push(event.phase);
|
||||
if (event.phase === phase) {
|
||||
requested = true;
|
||||
setImmediate(() => controller.abort());
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(requested).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok)
|
||||
throw new Error("Cancelled analysis must not publish Evidence");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
expect(terminal).toEqual([]);
|
||||
});
|
||||
|
||||
it("honors cancellation observed during the terminal publication race", async () => {
|
||||
const root = await createTestTempDirectory("rea-js-cancel-terminal-");
|
||||
await writeFile(join(root, "main.js"), "export const value = 1;\n");
|
||||
const controller = new AbortController();
|
||||
const result = await analyzeJavaScriptApplication(
|
||||
{ input_path: root, format: "directory" },
|
||||
{
|
||||
signal: controller.signal,
|
||||
progress: {
|
||||
async report(event) {
|
||||
if (event.terminal) controller.abort();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected cancellation before admission");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
createEvidence,
|
||||
createImmutableEvidenceSteps,
|
||||
type Evidence,
|
||||
type EvidenceObservation,
|
||||
} from "../../domain/evidence.js";
|
||||
|
|
@ -8,8 +9,14 @@ import type {
|
|||
JavaScriptApplicationAnalysisResult,
|
||||
} from "../../domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
import { freezeJsonSnapshot } from "../../domain/immutableJson.js";
|
||||
import {
|
||||
freezeJsonSnapshot,
|
||||
freezeOwnedJsonSnapshotSteps,
|
||||
} from "../../domain/immutableJson.js";
|
||||
import { rememberOwnedApplicationGraphEvidence } from "./JavaScriptApplicationEvidenceGraph.js";
|
||||
import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js";
|
||||
import { jsonValueValidationSteps } from "../../domain/jsonValue.js";
|
||||
import type { ProgressReporter } from "../ProgressReporter.js";
|
||||
|
||||
/** Transfer an already validated, application-owned result into immutable analysis Evidence. */
|
||||
export const createOwnedJavaScriptApplicationEvidence = (
|
||||
|
|
@ -21,32 +28,89 @@ export const createOwnedJavaScriptApplicationEvidence = (
|
|||
result,
|
||||
);
|
||||
|
||||
/** Create owned analysis Evidence while allowing request cancellation during sealing and hashing. */
|
||||
export const createOwnedJavaScriptApplicationEvidenceCooperatively = async (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
signal?: AbortSignal,
|
||||
progress?: ProgressReporter,
|
||||
): Promise<Evidence> => {
|
||||
await reportEvidencePhase(
|
||||
progress,
|
||||
"seal_javascript_application_result",
|
||||
"Sealing the complete application analysis result",
|
||||
);
|
||||
const snapshot = await completeJavaScriptAnalysisSteps(
|
||||
freezeOwnedJsonSnapshotSteps(result),
|
||||
signal,
|
||||
);
|
||||
await reportEvidencePhase(
|
||||
progress,
|
||||
"validate_javascript_application_evidence",
|
||||
"Validating the immutable application Evidence JSON",
|
||||
);
|
||||
// The Evidence envelope consumes the completed immutable validation cache.
|
||||
await completeJavaScriptAnalysisSteps(
|
||||
jsonValueValidationSteps(snapshot),
|
||||
signal,
|
||||
);
|
||||
await reportEvidencePhase(
|
||||
progress,
|
||||
"hash_javascript_application_evidence",
|
||||
"Hashing the complete application analysis Evidence",
|
||||
);
|
||||
const evidence = await completeJavaScriptAnalysisSteps(
|
||||
createImmutableEvidenceSteps(
|
||||
applicationSubject(snapshot),
|
||||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
applicationObservation(input, snapshot),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
return rememberOwnedApplicationGraphEvidence(evidence, snapshot);
|
||||
};
|
||||
|
||||
const reportEvidencePhase = (
|
||||
progress: ProgressReporter | undefined,
|
||||
phase: string,
|
||||
message: string,
|
||||
): Promise<void> =>
|
||||
progress?.report({ phase, completed: 0, total: 1, message }) ??
|
||||
Promise.resolve();
|
||||
|
||||
/** Create Evidence for one deterministic local JavaScript application graph. */
|
||||
export const createJavaScriptApplicationEvidence = (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): Evidence =>
|
||||
createEvidence(
|
||||
{
|
||||
path: result.input_path,
|
||||
sha256: result.root_artifact_sha256,
|
||||
format: result.format,
|
||||
},
|
||||
applicationSubject(result),
|
||||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
{
|
||||
predicateType: "rea.javascript-application-analysis",
|
||||
operation: "analyze_javascript_application",
|
||||
parameters: parameters(input),
|
||||
result,
|
||||
rawResult: null,
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: "artifact-root" }],
|
||||
},
|
||||
applicationObservation(input, result),
|
||||
);
|
||||
|
||||
const applicationSubject = (result: JavaScriptApplicationAnalysisResult) => ({
|
||||
path: result.input_path,
|
||||
sha256: result.root_artifact_sha256,
|
||||
format: result.format,
|
||||
});
|
||||
|
||||
const applicationObservation = (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
result: JavaScriptApplicationAnalysisResult,
|
||||
): EvidenceObservation => ({
|
||||
predicateType: "rea.javascript-application-analysis",
|
||||
operation: "analyze_javascript_application",
|
||||
parameters: parameters(input),
|
||||
result,
|
||||
rawResult: null,
|
||||
confidence: "derived",
|
||||
authority: "shipped-artifact",
|
||||
environment: null,
|
||||
limitations: result.limitations,
|
||||
locations: [{ kind: "artifact-path", path: "artifact-root" }],
|
||||
});
|
||||
|
||||
const parameters = (
|
||||
input: AnalyzeJavaScriptApplicationInput,
|
||||
): EvidenceObservation["parameters"] => ({
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.
|
|||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import { createOwnedJavaScriptApplicationEvidence } from "./JavaScriptApplicationEvidence.js";
|
||||
import { createOwnedJavaScriptApplicationEvidenceCooperatively } from "./JavaScriptApplicationEvidence.js";
|
||||
import {
|
||||
assertJavaScriptAnalysisActive,
|
||||
checkpointJavaScriptAnalysis,
|
||||
} from "./JavaScriptAnalysisControl.js";
|
||||
import { reconstructJavaScriptArtifact } from "./JavaScriptArtifactReconstruction.js";
|
||||
import { JAVASCRIPT_APPLICATION_PROVIDER } from "../InvestigationProviders.js";
|
||||
|
||||
|
|
@ -37,13 +41,15 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
input: z.output<typeof analyzeJavaScriptApplicationInputSchema>,
|
||||
options: ExecutionOptions = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Inventorying and parsing application artifacts",
|
||||
});
|
||||
try {
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Inventorying and parsing application artifacts",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const reconstructed = await reconstructJavaScriptArtifact(
|
||||
{
|
||||
input_path: input.input_path,
|
||||
|
|
@ -59,6 +65,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
total: 1,
|
||||
message: "Validating the application analysis result",
|
||||
});
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse({
|
||||
...application,
|
||||
summary,
|
||||
|
|
@ -70,7 +77,15 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
total: 1,
|
||||
message: "Creating and hashing application analysis Evidence",
|
||||
});
|
||||
const evidence = createOwnedJavaScriptApplicationEvidence(input, result);
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
const evidence =
|
||||
await createOwnedJavaScriptApplicationEvidenceCooperatively(
|
||||
input,
|
||||
result,
|
||||
options.signal,
|
||||
options.progress,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(options.signal);
|
||||
await options.progress?.report({
|
||||
phase: "analyze_javascript_application",
|
||||
completed: 1,
|
||||
|
|
@ -78,6 +93,7 @@ export const analyzeJavaScriptApplicationValidated = async (
|
|||
message: "Application graph and Electron boundaries reconstructed",
|
||||
terminal: true,
|
||||
});
|
||||
assertJavaScriptAnalysisActive(options.signal);
|
||||
return ok(evidence);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof ArtifactReaderFailure)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { scanCanonicalArtifactInventory } from "../../artifacts/inventory/Artifa
|
|||
import { summarizeElectronBoundaries } from "./ElectronBoundaryAnalysis.js";
|
||||
import { createJavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js";
|
||||
import type { ProgressReporter } from "../ProgressReporter.js";
|
||||
import { checkpointJavaScriptAnalysis } from "./JavaScriptAnalysisControl.js";
|
||||
|
||||
/** Application-layer result retaining local diagnostics outside the canonical graph. */
|
||||
export interface JavaScriptArtifactReconstructionResult {
|
||||
|
|
@ -50,6 +51,7 @@ export const reconstructJavaScriptArtifact = async (
|
|||
): Promise<JavaScriptArtifactReconstructionResult> => {
|
||||
const reportPhase = async (phase: string, message: string): Promise<void> => {
|
||||
await progress?.report({ phase, completed: 0, total: 1, message });
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
};
|
||||
const input = javascriptArtifactReconstructionInputSchema.parse(rawInput);
|
||||
abortIfNeeded(signal);
|
||||
|
|
@ -87,6 +89,7 @@ export const reconstructJavaScriptArtifact = async (
|
|||
abortIfNeeded(signal);
|
||||
},
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
abortIfNeeded(signal);
|
||||
await reportPhase(
|
||||
"build_javascript_application_graph",
|
||||
|
|
@ -102,6 +105,7 @@ export const reconstructJavaScriptArtifact = async (
|
|||
graph,
|
||||
analysis,
|
||||
);
|
||||
await checkpointJavaScriptAnalysis(signal);
|
||||
return {
|
||||
input_path: path,
|
||||
format,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from "../cliObservationOptions.js";
|
||||
import { runCliJavaScriptApplicationAnalysis } from "./javascriptApplicationAnalysis.js";
|
||||
import type { CliResultOutput } from "./streamedJsonOutput.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Register CLI equivalents of the Electron MCP tools. */
|
||||
export const registerElectronCommands = (
|
||||
|
|
@ -184,16 +185,19 @@ const registerJavaScriptApplicationCommand = (
|
|||
}),
|
||||
options: javascriptApplicationOptions,
|
||||
run: ({ args, options, format }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
|
||||
runCliJavaScriptApplicationAnalysis(
|
||||
{ input_path: args.path, format: options.artifactFormat },
|
||||
resultOutput === undefined
|
||||
? undefined
|
||||
: {
|
||||
output: resultOutput,
|
||||
command: CLI_COMMANDS.analyzeJavaScriptApplication,
|
||||
format,
|
||||
},
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.analyzeJavaScriptApplication, () =>
|
||||
runCliJavaScriptApplicationAnalysis(
|
||||
{ input_path: args.path, format: options.artifactFormat },
|
||||
resultOutput === undefined
|
||||
? undefined
|
||||
: {
|
||||
output: resultOutput,
|
||||
command: CLI_COMMANDS.analyzeJavaScriptApplication,
|
||||
format,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { CliCommandOutput } from "./streamedJsonOutput.js";
|
|||
export const runCliJavaScriptApplicationAnalysis = async (
|
||||
input: unknown,
|
||||
output?: CliCommandOutput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<JsonValue | undefined> => {
|
||||
const started = performance.now();
|
||||
const progress = createProgressReporter(
|
||||
|
|
@ -16,7 +17,10 @@ export const runCliJavaScriptApplicationAnalysis = async (
|
|||
},
|
||||
{ minimumIntervalMs: 0 },
|
||||
);
|
||||
const result = await analyzeJavaScriptApplication(input, { progress });
|
||||
const result = await analyzeJavaScriptApplication(input, {
|
||||
progress,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const value = result.ok ? result.value : cliError(result.error);
|
||||
if (
|
||||
output !== undefined &&
|
||||
|
|
@ -26,7 +30,8 @@ export const runCliJavaScriptApplicationAnalysis = async (
|
|||
duration: `${Math.round(performance.now() - started)}ms`,
|
||||
}))
|
||||
)
|
||||
return undefined;
|
||||
// Keep failures visible to command logging after the stream has handled stdout.
|
||||
return result.ok ? undefined : value;
|
||||
return value;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { fc, it } from "@fast-check/vitest";
|
||||
import { describe, expect } from "vitest";
|
||||
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
|
||||
const complete = <Value>(steps: Iterator<void, Value>): Value => {
|
||||
for (;;) {
|
||||
const next = steps.next();
|
||||
if (next.done) return next.value;
|
||||
}
|
||||
};
|
||||
|
||||
describe("cooperative canonical JSON digest", () => {
|
||||
const values: readonly JsonValue[] = [
|
||||
null,
|
||||
[true, false, 0, -0, 1e-7, 1e21, "\ud800", '𐀀\n"'],
|
||||
{ z: null, a: { b: 2, a: 1 } },
|
||||
jsonValueSchema.parse(
|
||||
JSON.parse('{"__proto__":{"x":1},"constructor":2,"toJSON":"text"}'),
|
||||
),
|
||||
{ payload: `${"x".repeat(8191)}𐀀${"\n\ud800".repeat(20_000)}` },
|
||||
{ [`${"x".repeat(8191)}𐀀${"\n".repeat(20_000)}`]: "value" },
|
||||
];
|
||||
it.each(values.map((value) => ({ value })))(
|
||||
"preserves complete canonical meaning (%#)",
|
||||
({ value }) => {
|
||||
expect(
|
||||
complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))),
|
||||
).toBe(digestCanonicalValue(value));
|
||||
},
|
||||
);
|
||||
|
||||
it("supports repeated references without treating them as cycles", () => {
|
||||
const shared = { nested: ["value"] };
|
||||
const value = { a: shared, b: shared };
|
||||
expect(complete(canonicalJsonDigestSteps(value))).toBe(
|
||||
digestCanonicalValue(value),
|
||||
);
|
||||
});
|
||||
|
||||
it.prop([fc.jsonValue()])(
|
||||
"matches the existing digest across JSON values",
|
||||
(value) => {
|
||||
expect(
|
||||
complete(canonicalJsonDigestSteps(jsonValueSchema.parse(value))),
|
||||
).toBe(digestCanonicalValue(value));
|
||||
},
|
||||
);
|
||||
|
||||
it("permits abandoning a large string before completing its digest", () => {
|
||||
const steps = canonicalJsonDigestSteps("𐀀".repeat(100_000));
|
||||
expect(steps.next().done).toBe(false);
|
||||
steps.return("abandoned");
|
||||
expect(steps.next().done).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects sparse arrays, nonfinite numbers and cycles", () => {
|
||||
const sparse: JsonValue[] = [];
|
||||
sparse.length = 2;
|
||||
const cycle: { [key: string]: JsonValue } = {};
|
||||
cycle.self = cycle;
|
||||
for (const value of [sparse, Number.NaN, cycle])
|
||||
expect(() => complete(canonicalJsonDigestSteps(value))).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
type DigestFrame =
|
||||
| { readonly kind: "separator" }
|
||||
| { readonly kind: "value"; readonly value: JsonValue }
|
||||
| { readonly kind: "string"; readonly value: string; index: number }
|
||||
| {
|
||||
readonly kind: "array";
|
||||
readonly value: readonly JsonValue[];
|
||||
readonly length: number;
|
||||
index: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "object";
|
||||
readonly value: { readonly [key: string]: JsonValue };
|
||||
readonly keys: readonly string[];
|
||||
index: number;
|
||||
written: number;
|
||||
};
|
||||
|
||||
/** Hash owned, validated JSON in bounded steps without a document-sized string. */
|
||||
export function* canonicalJsonDigestSteps(
|
||||
value: JsonValue,
|
||||
): Generator<void, string> {
|
||||
const hash = createHash("sha256");
|
||||
const ancestors = new Set<object>();
|
||||
const stack: DigestFrame[] = [{ kind: "value", value }];
|
||||
let buffered = "";
|
||||
let flushed = false;
|
||||
const emit = (part: string): void => {
|
||||
buffered += part;
|
||||
if (buffered.length >= 8192) {
|
||||
hash.update(buffered);
|
||||
buffered = "";
|
||||
flushed = true;
|
||||
}
|
||||
};
|
||||
try {
|
||||
while (stack.length > 0) {
|
||||
if (flushed) {
|
||||
flushed = false;
|
||||
yield;
|
||||
}
|
||||
const frame = stack.pop();
|
||||
if (frame === undefined) break;
|
||||
if (frame.kind === "separator") {
|
||||
emit(":");
|
||||
continue;
|
||||
}
|
||||
if (frame.kind === "string") {
|
||||
if (frame.index === frame.value.length) {
|
||||
emit('"');
|
||||
continue;
|
||||
}
|
||||
let end = Math.min(frame.index + 8192, frame.value.length);
|
||||
const last = frame.value.charCodeAt(end - 1);
|
||||
const next = frame.value.charCodeAt(end);
|
||||
if (
|
||||
end < frame.value.length &&
|
||||
last >= 0xd800 &&
|
||||
last <= 0xdbff &&
|
||||
next >= 0xdc00 &&
|
||||
next <= 0xdfff
|
||||
)
|
||||
end -= 1;
|
||||
emit(JSON.stringify(frame.value.slice(frame.index, end)).slice(1, -1));
|
||||
frame.index = end;
|
||||
stack.push(frame);
|
||||
continue;
|
||||
}
|
||||
if (frame.kind === "array") {
|
||||
if (frame.index === frame.length) {
|
||||
emit("]");
|
||||
ancestors.delete(frame.value);
|
||||
continue;
|
||||
}
|
||||
const item = frame.value[frame.index];
|
||||
if (item === undefined)
|
||||
throw new TypeError(
|
||||
"Canonical JSON digest requires dense JSON arrays",
|
||||
);
|
||||
if (frame.index++ > 0) emit(",");
|
||||
stack.push(frame, { kind: "value", value: item });
|
||||
continue;
|
||||
}
|
||||
if (frame.kind === "object") {
|
||||
if (frame.index === frame.keys.length) {
|
||||
emit("}");
|
||||
ancestors.delete(frame.value);
|
||||
continue;
|
||||
}
|
||||
const key = frame.keys[frame.index++];
|
||||
if (key === undefined)
|
||||
throw new TypeError("Canonical JSON object changed during hashing");
|
||||
const item = frame.value[key];
|
||||
if (item === undefined) continue;
|
||||
if (frame.written++ > 0) emit(",");
|
||||
if (key.length > 8192) {
|
||||
emit('"');
|
||||
stack.push(
|
||||
frame,
|
||||
{ kind: "value", value: item },
|
||||
{ kind: "separator" },
|
||||
{ kind: "string", value: key, index: 0 },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
emit(JSON.stringify(key));
|
||||
emit(":");
|
||||
stack.push(frame, { kind: "value", value: item });
|
||||
continue;
|
||||
}
|
||||
const item = frame.value;
|
||||
if (typeof item === "string" && item.length > 8192) {
|
||||
emit('"');
|
||||
stack.push({ kind: "string", value: item, index: 0 });
|
||||
} else if (item === null || typeof item !== "object") {
|
||||
if (typeof item === "number" && !Number.isFinite(item))
|
||||
throw new Error(
|
||||
Number.isNaN(item)
|
||||
? "NaN is not allowed"
|
||||
: "Infinity is not allowed",
|
||||
);
|
||||
emit(JSON.stringify(item));
|
||||
} else {
|
||||
if (ancestors.has(item)) throw new Error("Circular reference detected");
|
||||
ancestors.add(item);
|
||||
if (Array.isArray(item)) {
|
||||
emit("[");
|
||||
stack.push({
|
||||
kind: "array",
|
||||
value: item,
|
||||
length: item.length,
|
||||
index: 0,
|
||||
});
|
||||
} else {
|
||||
emit("{");
|
||||
stack.push({
|
||||
kind: "object",
|
||||
value: item,
|
||||
keys: Object.keys(item).sort(),
|
||||
index: 0,
|
||||
written: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (buffered !== "") hash.update(buffered);
|
||||
return hash.digest("hex");
|
||||
} finally {
|
||||
stack.length = 0;
|
||||
ancestors.clear();
|
||||
buffered = "";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
|
||||
|
||||
import {
|
||||
analysisProfileSchema,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
jsonValueValidationIssue,
|
||||
jsonValueValidationSteps,
|
||||
type JsonValue,
|
||||
} from "./jsonValue.js";
|
||||
import {
|
||||
|
|
@ -223,7 +225,7 @@ const semanticProjection = (evidence: EvidenceWithoutId): JsonValue => ({
|
|||
evidence_links: evidence.evidence_links,
|
||||
});
|
||||
|
||||
/** Recompute the semantic identifier, excluding paths and raw payload bytes. */
|
||||
/** Hash semantic content, including raw results and excluding display-subject fields. */
|
||||
const computeEvidenceId = (evidence: EvidenceWithoutId): string =>
|
||||
`ev_${digestCanonicalValue(semanticProjection(evidence), "Evidence")}`;
|
||||
|
||||
|
|
@ -270,6 +272,50 @@ export const createEvidence = (
|
|||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservation,
|
||||
): Evidence => {
|
||||
const { normalized, sharedResult } = normalizeEvidenceObservation(
|
||||
target,
|
||||
provider,
|
||||
observation,
|
||||
);
|
||||
const evidence = {
|
||||
...normalized,
|
||||
evidence_id: computeEvidenceId(normalized),
|
||||
};
|
||||
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
|
||||
};
|
||||
|
||||
/** Create Evidence from owned immutable JSON while exposing cooperative computation steps. */
|
||||
export function* createImmutableEvidenceSteps(
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservation,
|
||||
): Generator<void, Evidence> {
|
||||
if (!isImmutableJsonSnapshot(observation.result))
|
||||
throw new TypeError(
|
||||
"Cooperative Evidence requires an authenticated immutable result",
|
||||
);
|
||||
// The envelope parser below retains its usual diagnostics, using the completed
|
||||
// immutable validation rather than traversing the payload synchronously again.
|
||||
yield* jsonValueValidationSteps(observation.result);
|
||||
const { normalized } = normalizeEvidenceObservation(
|
||||
target,
|
||||
provider,
|
||||
observation,
|
||||
);
|
||||
const digest = yield* canonicalJsonDigestSteps(
|
||||
semanticProjection(normalized),
|
||||
);
|
||||
return rememberImmutableEvidence({
|
||||
...normalized,
|
||||
evidence_id: `ev_${digest}`,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizeEvidenceObservation = (
|
||||
target: EvidenceSubjectTarget | BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservation,
|
||||
) => {
|
||||
const subject =
|
||||
target === undefined
|
||||
? null
|
||||
|
|
@ -339,9 +385,5 @@ export const createEvidence = (
|
|||
// The envelope has already been parsed into an independent snapshot. Only
|
||||
// its derived identifier changes here; parsing again clones the full payload
|
||||
// and recomputes the same digest while the previous snapshot is still live.
|
||||
const evidence = {
|
||||
...normalized,
|
||||
evidence_id: computeEvidenceId(normalized),
|
||||
};
|
||||
return sharedResult ? rememberImmutableEvidence(evidence) : evidence;
|
||||
return { normalized, sharedResult };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createAnalysisProfile } from "./analysisProfile.js";
|
||||
import {
|
||||
createEvidence,
|
||||
createImmutableEvidenceSteps,
|
||||
isImmutableEvidence,
|
||||
parseEvidence,
|
||||
} from "./evidence.js";
|
||||
import { freezeJsonSnapshot } from "./immutableJson.js";
|
||||
import {
|
||||
jsonValueValidationSteps,
|
||||
MAX_JSON_DEPTH,
|
||||
type JsonValue,
|
||||
} from "./jsonValue.js";
|
||||
|
||||
const provider = {
|
||||
id: "cooperative",
|
||||
name: "Cooperative fixture",
|
||||
version: "1",
|
||||
};
|
||||
const target = {
|
||||
path: "/artifact",
|
||||
sha256: "a".repeat(64),
|
||||
format: "file",
|
||||
} as const;
|
||||
|
||||
const finish = <Value>(steps: Iterator<void, Value, void>): Value => {
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) return step.value;
|
||||
}
|
||||
};
|
||||
|
||||
describe("cooperative immutable Evidence", () => {
|
||||
it.each([false, true])(
|
||||
"preserves the complete envelope and identifier (profile: %s)",
|
||||
(profiled) => {
|
||||
const result = freezeJsonSnapshot({
|
||||
["__proto__"]: { preserved: [1, "\ud800", "😀", null] },
|
||||
constructor: "ordinary member",
|
||||
long: 'quote"\\\n😀'.repeat(5000),
|
||||
});
|
||||
const observation = {
|
||||
operation: "inspect",
|
||||
parameters: { ["__proto__"]: "caller-selected" },
|
||||
result,
|
||||
rawResult: { retained: true },
|
||||
...(profiled
|
||||
? { analysisProfile: createAnalysisProfile(provider, {}) }
|
||||
: {}),
|
||||
};
|
||||
const ordinary = createEvidence(target, provider, observation);
|
||||
const cooperative = finish(
|
||||
createImmutableEvidenceSteps(target, provider, observation),
|
||||
);
|
||||
expect(JSON.stringify(cooperative)).toBe(JSON.stringify(ordinary));
|
||||
expect(cooperative.normalized_result).toBe(result);
|
||||
expect(isImmutableEvidence(cooperative)).toBe(true);
|
||||
expect(parseEvidence(cooperative)).toBe(cooperative);
|
||||
expect(parseEvidence(JSON.parse(JSON.stringify(cooperative)))).toEqual(
|
||||
ordinary,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains scalar results and unavailable-subject limitations", () => {
|
||||
const observation = { operation: "inspect", parameters: {}, result: null };
|
||||
expect(
|
||||
finish(createImmutableEvidenceSteps(undefined, provider, observation)),
|
||||
).toEqual(createEvidence(undefined, provider, observation));
|
||||
});
|
||||
|
||||
it("rejects mutable results and mismatched producer profiles", () => {
|
||||
expect(() =>
|
||||
finish(
|
||||
createImmutableEvidenceSteps(target, provider, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: { borrowed: true },
|
||||
}),
|
||||
),
|
||||
).toThrow("authenticated immutable result");
|
||||
expect(() =>
|
||||
finish(
|
||||
createImmutableEvidenceSteps(target, provider, {
|
||||
operation: "inspect",
|
||||
parameters: {},
|
||||
result: freezeJsonSnapshot({ value: true }),
|
||||
analysisProfile: createAnalysisProfile(
|
||||
{ ...provider, id: "different" },
|
||||
{},
|
||||
),
|
||||
}),
|
||||
),
|
||||
).toThrow("profile provider");
|
||||
});
|
||||
|
||||
it("does not cache an interrupted depth validation as successful", () => {
|
||||
let nested: JsonValue = 1;
|
||||
for (let index = 0; index <= MAX_JSON_DEPTH; index += 1)
|
||||
nested = { nested };
|
||||
const result = freezeJsonSnapshot([
|
||||
nested,
|
||||
...Array<JsonValue>(5000).fill(null),
|
||||
]);
|
||||
const validation = jsonValueValidationSteps(result);
|
||||
expect(validation.next().done).toBe(false);
|
||||
validation.return(undefined);
|
||||
const observation = { operation: "inspect", parameters: {}, result };
|
||||
expect(() =>
|
||||
finish(createImmutableEvidenceSteps(target, provider, observation)),
|
||||
).toThrow("maximum nesting depth");
|
||||
expect(() => createEvidence(target, provider, observation)).toThrow(
|
||||
"maximum nesting depth",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import type { JsonValue } from "./jsonValue.js";
|
||||
|
||||
const immutableSnapshots = new WeakSet<object>();
|
||||
interface SnapshotEntry {
|
||||
readonly value: unknown;
|
||||
readonly exiting: boolean;
|
||||
}
|
||||
|
||||
/** Recognize an owned JSON snapshot sealed by this module, or an immutable scalar. */
|
||||
export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
|
||||
|
|
@ -12,9 +16,7 @@ export const isImmutableJsonSnapshot = (value: unknown): value is JsonValue =>
|
|||
|
||||
/** Seal an already parsed, owned JSON snapshot for immutable zero-copy reuse. */
|
||||
export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
||||
const pending: { value: unknown; exiting: boolean }[] = [
|
||||
{ value, exiting: false },
|
||||
];
|
||||
const pending: SnapshotEntry[] = [{ value, exiting: false }];
|
||||
// These sets exist only during traversal, while the owned root already
|
||||
// retains every child. Strong sets avoid per-node ephemeron GC work.
|
||||
const visited = new Set<object>();
|
||||
|
|
@ -33,13 +35,6 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
|||
if (isImmutableJsonSnapshot(item)) continue;
|
||||
if (typeof item !== "object" || item === null)
|
||||
throw new TypeError("Immutable JSON snapshots require JSON values");
|
||||
const prototype: unknown = Object.getPrototypeOf(item);
|
||||
if (
|
||||
!Array.isArray(item) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
)
|
||||
throw new TypeError("Immutable JSON snapshots require ordinary objects");
|
||||
if (ancestors.has(item))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain circular references",
|
||||
|
|
@ -47,21 +42,7 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
|||
if (visited.has(item)) continue;
|
||||
ancestors.add(item);
|
||||
pending.push({ value: item, exiting: true });
|
||||
if (Array.isArray(item))
|
||||
for (let index = 0; index < item.length; index += 1)
|
||||
if (!Object.hasOwn(item, index))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain sparse arrays",
|
||||
);
|
||||
for (const key of Object.getOwnPropertyNames(item)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
||||
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain accessors",
|
||||
);
|
||||
const child: unknown = descriptor.value;
|
||||
pending.push({ value: child, exiting: false });
|
||||
}
|
||||
pushSnapshotChildren(item, pending);
|
||||
}
|
||||
// Register only after every value is checked and every object is frozen.
|
||||
// An arbitrary Object.freeze call cannot forge this snapshot identity.
|
||||
|
|
@ -72,3 +53,75 @@ export const freezeJsonSnapshot = <T extends JsonValue>(value: T): T => {
|
|||
immutableSnapshots.add(value);
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Seal exclusively owned JSON in bounded steps, authenticating only completion.
|
||||
* Abandonment or failure can leave the provisional input partly frozen.
|
||||
*/
|
||||
export function* freezeOwnedJsonSnapshotSteps<T extends JsonValue>(
|
||||
value: T,
|
||||
): Generator<void, T> {
|
||||
const pending: SnapshotEntry[] = [{ value, exiting: false }];
|
||||
const visited = new Set<object>();
|
||||
const ancestors = new Set<object>();
|
||||
let examined = 0;
|
||||
try {
|
||||
while (pending.length > 0) {
|
||||
if (examined++ === 4096) {
|
||||
examined = 0;
|
||||
yield;
|
||||
}
|
||||
const entry = pending.pop();
|
||||
if (entry === undefined) break;
|
||||
const item = entry.value;
|
||||
if (entry.exiting && typeof item === "object" && item !== null) {
|
||||
ancestors.delete(item);
|
||||
visited.add(item);
|
||||
continue;
|
||||
}
|
||||
if (isImmutableJsonSnapshot(item)) continue;
|
||||
if (typeof item !== "object" || item === null)
|
||||
throw new TypeError("Immutable JSON snapshots require JSON values");
|
||||
if (ancestors.has(item))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain circular references",
|
||||
);
|
||||
if (visited.has(item)) continue;
|
||||
ancestors.add(item);
|
||||
pending.push({ value: item, exiting: true });
|
||||
pushSnapshotChildren(item, pending);
|
||||
// Lock every inspected edge before the scheduler can release control.
|
||||
Object.freeze(item);
|
||||
}
|
||||
if (typeof value === "object" && value !== null)
|
||||
immutableSnapshots.add(value);
|
||||
return value;
|
||||
} finally {
|
||||
pending.length = 0;
|
||||
visited.clear();
|
||||
ancestors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const pushSnapshotChildren = (item: object, pending: SnapshotEntry[]): void => {
|
||||
const prototype: unknown = Object.getPrototypeOf(item);
|
||||
if (
|
||||
!Array.isArray(item) &&
|
||||
prototype !== Object.prototype &&
|
||||
prototype !== null
|
||||
)
|
||||
throw new TypeError("Immutable JSON snapshots require ordinary objects");
|
||||
if (Array.isArray(item))
|
||||
for (let index = 0; index < item.length; index += 1)
|
||||
if (!Object.hasOwn(item, index))
|
||||
throw new TypeError(
|
||||
"Immutable JSON snapshots cannot contain sparse arrays",
|
||||
);
|
||||
for (const key of Object.getOwnPropertyNames(item)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
||||
if (descriptor === undefined || !Object.hasOwn(descriptor, "value"))
|
||||
throw new TypeError("Immutable JSON snapshots cannot contain accessors");
|
||||
const child: unknown = descriptor.value;
|
||||
pending.push({ value: child, exiting: false });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
freezeOwnedJsonSnapshotSteps,
|
||||
isImmutableJsonSnapshot,
|
||||
} from "./immutableJson.js";
|
||||
import { jsonValueValidationIssue, type JsonValue } from "./jsonValue.js";
|
||||
|
||||
const complete = <Value>(steps: Iterator<void, Value>): Value => {
|
||||
for (;;) {
|
||||
const next = steps.next();
|
||||
if (next.done) return next.value;
|
||||
}
|
||||
};
|
||||
|
||||
describe("cooperative owned snapshot sealing", () => {
|
||||
it("locks reachable edges before yielding and authenticates only completion", () => {
|
||||
const deferred: { [key: string]: JsonValue } = { value: 1 };
|
||||
const value = {
|
||||
deferred,
|
||||
many: Array.from({ length: 10_000 }, () => ({ value: 1 })),
|
||||
};
|
||||
const steps = freezeOwnedJsonSnapshotSteps(value);
|
||||
expect(steps.next().done).toBe(false);
|
||||
expect(Object.isFrozen(value)).toBe(true);
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(false);
|
||||
const introduced = { value: 2 };
|
||||
deferred.child = introduced;
|
||||
expect(complete(steps)).toBe(value);
|
||||
expect(Object.isFrozen(introduced)).toBe(true);
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(true);
|
||||
expect(jsonValueValidationIssue(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never authenticates abandoned or invalid provisional roots", () => {
|
||||
const deferred: { [key: string]: JsonValue } = { value: 1 };
|
||||
const value = {
|
||||
deferred,
|
||||
many: Array.from({ length: 10_000 }, () => ({ value: 1 })),
|
||||
};
|
||||
const steps = freezeOwnedJsonSnapshotSteps(value);
|
||||
expect(steps.next().done).toBe(false);
|
||||
deferred.value = Number.NaN;
|
||||
expect(() => complete(steps)).toThrow();
|
||||
expect(isImmutableJsonSnapshot(value)).toBe(false);
|
||||
const cancelled = {
|
||||
values: Array.from({ length: 10_000 }, () => ({ value: 1 })),
|
||||
};
|
||||
const interrupted = freezeOwnedJsonSnapshotSteps(cancelled);
|
||||
expect(interrupted.next().done).toBe(false);
|
||||
interrupted.return(cancelled);
|
||||
expect(isImmutableJsonSnapshot(cancelled)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves aliases and rejects accessors, sparse values and cycles", () => {
|
||||
const shared = { value: 1 };
|
||||
const value = { a: shared, b: shared };
|
||||
complete(freezeOwnedJsonSnapshotSteps(value));
|
||||
expect(Object.isFrozen(shared)).toBe(true);
|
||||
const sparse: JsonValue[] = [];
|
||||
sparse.length = 2;
|
||||
const cycle: { [key: string]: JsonValue } = {};
|
||||
cycle.self = cycle;
|
||||
let accessed = false;
|
||||
const accessor = {
|
||||
get value() {
|
||||
accessed = true;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
for (const malformed of [sparse, cycle, accessor]) {
|
||||
expect(() => complete(freezeOwnedJsonSnapshotSteps(malformed))).toThrow();
|
||||
expect(isImmutableJsonSnapshot(malformed)).toBe(false);
|
||||
}
|
||||
expect(accessed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cache validation of a caller's shallow freeze", () => {
|
||||
const value = Object.freeze({ child: { value: 1 } });
|
||||
expect(jsonValueValidationIssue(value)).toBeUndefined();
|
||||
value.child.value = Number.NaN;
|
||||
expect(jsonValueValidationIssue(value)).toBe("JSON numbers must be finite");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { isImmutableJsonSnapshot } from "./immutableJson.js";
|
||||
|
||||
/** Maximum JSON nesting accepted before recursive result cloning. */
|
||||
export const MAX_JSON_DEPTH = 1000;
|
||||
|
|
@ -15,17 +16,46 @@ export type JsonValue =
|
|||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
const recordShape = z.record(z.string(), z.unknown());
|
||||
const immutableValidation = new WeakMap<object, string | undefined>();
|
||||
|
||||
/** Validate JSON meaning and nesting without cloning a trusted immutable value. */
|
||||
export const jsonValueValidationIssue = (root: unknown): string | undefined => {
|
||||
const steps = jsonValueValidationSteps(root);
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) return step.value;
|
||||
}
|
||||
};
|
||||
|
||||
/** Validate JSON in bounded steps; cache only authenticated immutable roots. */
|
||||
export function* jsonValueValidationSteps(
|
||||
root: unknown,
|
||||
): Generator<void, string | undefined> {
|
||||
const immutable =
|
||||
typeof root === "object" && root !== null && isImmutableJsonSnapshot(root)
|
||||
? root
|
||||
: undefined;
|
||||
if (immutable !== undefined && immutableValidation.has(immutable))
|
||||
return immutableValidation.get(immutable);
|
||||
const finish = (issue: string | undefined): string | undefined => {
|
||||
if (immutable !== undefined) immutableValidation.set(immutable, issue);
|
||||
return issue;
|
||||
};
|
||||
const pending: { value: unknown; depth: number }[] = [
|
||||
{ value: root, depth: 0 },
|
||||
];
|
||||
let examined = 0;
|
||||
while (pending.length > 0) {
|
||||
if (examined++ === 4096) {
|
||||
examined = 0;
|
||||
yield;
|
||||
}
|
||||
const item = pending.pop();
|
||||
if (item === undefined) break;
|
||||
if (item.depth > MAX_JSON_DEPTH)
|
||||
return `JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`;
|
||||
return finish(
|
||||
`JSON value exceeds maximum nesting depth of ${MAX_JSON_DEPTH}`,
|
||||
);
|
||||
const value = item.value;
|
||||
if (
|
||||
value === null ||
|
||||
|
|
@ -34,7 +64,7 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => {
|
|||
)
|
||||
continue;
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) return "JSON numbers must be finite";
|
||||
if (!Number.isFinite(value)) return finish("JSON numbers must be finite");
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const child of value)
|
||||
pending.push({ value: child, depth: item.depth + 1 });
|
||||
|
|
@ -42,10 +72,10 @@ export const jsonValueValidationIssue = (root: unknown): string | undefined => {
|
|||
// Inspect every own member, including keys Zod's record parser skips.
|
||||
for (const child of Object.values(value))
|
||||
pending.push({ value: child, depth: item.depth + 1 });
|
||||
} else return "Invalid JSON value";
|
||||
} else return finish("Invalid JSON value");
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return finish(undefined);
|
||||
}
|
||||
|
||||
const preserveJsonValue = (value: JsonValue): JsonValue => {
|
||||
if (Array.isArray(value)) return value.map(preserveJsonValue);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { execFile, spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("returns a nonzero exit for a streamed JSON analysis failure", async () => {
|
||||
const root = await createTestTempDirectory("rea-cli-js-missing-");
|
||||
const result = await new Promise<{ code: number | string; stdout: string }>(
|
||||
(resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"analyze-javascript-application",
|
||||
join(root, "missing"),
|
||||
"--json",
|
||||
],
|
||||
(error, stdout) => {
|
||||
if (error !== null && error.code === undefined) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ code: error?.code ?? 0, stdout });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result.code).toBe(1);
|
||||
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||
code: "artifact_operation_failed",
|
||||
details: { reason: "io" },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"returns a typed cancelled CLI result and exit 130 after SIGINT",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-cli-js-cancellation-");
|
||||
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
["scripts/rea.mjs", "analyze-javascript-application", root, "--json"],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
onTestFinished(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill("SIGTERM");
|
||||
});
|
||||
let stdout = "",
|
||||
partial = "",
|
||||
requested = false;
|
||||
const progressSchema = z.object({
|
||||
rea_progress: z.object({ phase: z.string() }),
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
partial += chunk.toString();
|
||||
let newline: number;
|
||||
while ((newline = partial.indexOf("\n")) >= 0) {
|
||||
const line = partial.slice(0, newline);
|
||||
partial = partial.slice(newline + 1);
|
||||
const parsed = progressSchema.safeParse(JSON.parse(line));
|
||||
if (parsed.success && !requested) {
|
||||
requested = true;
|
||||
child.kill("SIGINT");
|
||||
}
|
||||
}
|
||||
});
|
||||
const exited = await new Promise<{
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}>((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
expect(requested).toBe(true);
|
||||
expect(exited).toEqual({ code: 130, signal: null });
|
||||
expect(JSON.parse(stdout)).toMatchObject({
|
||||
code: "cancelled",
|
||||
details: { reason: "cancelled" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import {
|
||||
createDeferred,
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("stops a cancelled SDK request on the server, retains prior Evidence, and accepts subsequent work", async () => {
|
||||
const root = await createTestTempDirectory("rea-mcp-js-cancellation-");
|
||||
await writeFile(join(root, "main.js"), "export const observed = 1;\n");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error(
|
||||
"Static JavaScript analysis must not start a binary provider",
|
||||
);
|
||||
});
|
||||
const prior = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: "prior_observation",
|
||||
parameters: {},
|
||||
result: { preserved: true },
|
||||
},
|
||||
);
|
||||
expect(session.recordEvidence(prior).ok).toBe(true);
|
||||
const completed = createDeferred<string>();
|
||||
const completionSchema = z.object({
|
||||
tool: z.string(),
|
||||
status: z.string(),
|
||||
msg: z.string(),
|
||||
});
|
||||
const logger = pino(
|
||||
{ level: "info" },
|
||||
{
|
||||
write(line) {
|
||||
const value: unknown = JSON.parse(line);
|
||||
const parsed = completionSchema.safeParse(value);
|
||||
if (
|
||||
parsed.success &&
|
||||
parsed.data.tool === "analyze_javascript_application" &&
|
||||
parsed.data.msg === "MCP tool execution completed"
|
||||
)
|
||||
completed.resolve(parsed.data.status);
|
||||
},
|
||||
},
|
||||
);
|
||||
const server = createServer(session, session, { logger });
|
||||
const client = new Client({ name: "javascript-cancellation", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const controller = new AbortController();
|
||||
let requested = false;
|
||||
const call = client.callTool(
|
||||
{
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: controller.signal,
|
||||
onprogress() {
|
||||
requested = true;
|
||||
controller.abort(new Error("SDK cancellation verification"));
|
||||
},
|
||||
},
|
||||
);
|
||||
await expect(call).rejects.toThrow("SDK cancellation verification");
|
||||
expect(requested).toBe(true);
|
||||
// A rejected client promise alone does not prove that the server stopped.
|
||||
expect(await completed.promise).toBe("error");
|
||||
await client.ping();
|
||||
const bundle = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
expect(
|
||||
z
|
||||
.object({
|
||||
result: z.object({
|
||||
records: z.array(z.object({ evidence_id: z.string() })),
|
||||
}),
|
||||
})
|
||||
.parse(bundle.structuredContent).result.records,
|
||||
).toEqual([{ evidence_id: prior.evidence_id }]);
|
||||
const next = await client.callTool({
|
||||
name: "analyze_javascript_application",
|
||||
arguments: {
|
||||
input_path: root,
|
||||
format: "directory",
|
||||
},
|
||||
});
|
||||
expect(next.isError, JSON.stringify(next)).not.toBe(true);
|
||||
await client.ping();
|
||||
});
|
||||
Loading…
Reference in New Issue