mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
472b72a530
* feat(web): export captured scripts for JavaScript application analysis Add capture adapters, conservative source path projection, verified exclusive publication and a shared CLI/MCP workflow. Retain source identities, missing content and competing URL versions in a complete manifest for existing static analysis. Refs: #774 Closes: #799 Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * test(web): include script export in independent effect audit Register the new contract family in the independent inventory audit and verify the filesystem, session, network and idempotence metadata of the named export contract. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing) * fix(web): preserve capture limitation values in export manifests Align the export boundary with passive capture’s string contract instead of rejecting an admitted empty limitation. Cover the publisher boundary and regenerate canonical schemas and identities. Assisted-by: Yukari (gensokyo-skills:yukari-boundary-analysis) Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
266 lines
9.9 KiB
TypeScript
266 lines
9.9 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
readFile,
|
|
readdir,
|
|
symlink,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
|
import { exportWebScripts } from "../../../src/application/WebScriptExportService.js";
|
|
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
|
|
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
|
|
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascriptApplicationAnalysis.js";
|
|
import { webScriptExportResultSchema } from "../../../src/domain/webScriptExport.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
scriptCaptureEvidenceFixture,
|
|
scriptScenarioFixture,
|
|
} from "../../fixtures/webScriptCapture.js";
|
|
|
|
const setup = async (capture: unknown = scriptCaptureEvidenceFixture()) => {
|
|
const root = await createTestTempDirectory("rea-web-script-export-");
|
|
const input = {
|
|
capture_path: join(root, "capture.json"),
|
|
output_directory: join(root, "export"),
|
|
};
|
|
await writeFile(input.capture_path, JSON.stringify(capture));
|
|
return { root, input };
|
|
};
|
|
|
|
describe("captured script publication boundary", () => {
|
|
it("verifies durable bytes, manifest, capture identity, and existing module analysis", async () => {
|
|
const capture = scriptScenarioFixture([
|
|
{
|
|
url: "https://fixture.test/app/main.js",
|
|
bytes: Buffer.from(
|
|
"import { marker } from './lib/dep.js'; export const result = marker;\n",
|
|
),
|
|
},
|
|
{
|
|
url: "https://fixture.test/app/lib/dep.js",
|
|
bytes: Buffer.from("export const marker = 'source-owned';\n"),
|
|
},
|
|
]);
|
|
const { input } = await setup(capture);
|
|
const exported = await exportWebScripts(input);
|
|
if (!exported.ok) throw exported.error;
|
|
const result = webScriptExportResultSchema.parse(
|
|
exported.value.normalized_result,
|
|
);
|
|
expect(result.capture_sha256).toBe(
|
|
createHash("sha256")
|
|
.update(await readFile(input.capture_path))
|
|
.digest("hex"),
|
|
);
|
|
const manifest = await readFile(result.manifest.path);
|
|
expect(result.manifest.sha256).toBe(
|
|
createHash("sha256").update(manifest).digest("hex"),
|
|
);
|
|
const { manifest: descriptor, ...inline } = result;
|
|
expect(descriptor.bytes).toBe(manifest.length);
|
|
expect(JSON.parse(manifest.toString())).toEqual(inline);
|
|
if (result.analysis_input === null)
|
|
throw new Error("Missing analysis input");
|
|
for (const [index, script] of result.scripts.entries()) {
|
|
if (script.content.state !== "exported")
|
|
throw new Error("Expected exported script");
|
|
const bytes = await readFile(
|
|
join(result.analysis_input.input_path, script.content.relative_path),
|
|
);
|
|
expect(bytes).toEqual(
|
|
capture.events.items
|
|
.filter(({ kind }) => kind === "network-content")
|
|
.map((event) =>
|
|
event.kind === "network-content" && event.body.state === "captured"
|
|
? Buffer.from(event.body.content, "base64")
|
|
: Buffer.alloc(0),
|
|
)[index],
|
|
);
|
|
}
|
|
const analyzed = await analyzeJavaScriptApplication(result.analysis_input);
|
|
if (!analyzed.ok) throw analyzed.error;
|
|
const analysis = javascriptApplicationAnalysisResultSchema.parse(
|
|
analyzed.value.normalized_result,
|
|
);
|
|
expect(analysis.statistics.parsed_javascript_files).toBe(2);
|
|
expect(analysis.graph.edges).toContainEqual(
|
|
expect.objectContaining({
|
|
relation: "imports",
|
|
properties: expect.objectContaining({
|
|
specifier: "./lib/dep.js",
|
|
resolution_status: "resolved",
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("links authenticated input Evidence and preserves binary and empty source bytes", async () => {
|
|
const { input } = await setup();
|
|
const result = await exportWebScripts(input);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.evidence_links).toEqual([
|
|
scriptCaptureEvidenceFixture().evidence_id,
|
|
]);
|
|
const binary = Buffer.from([255, 0, 254, 1]);
|
|
const { input: binaryInput } = await setup(
|
|
scriptScenarioFixture([
|
|
{ url: "https://fixture.test/binary.js", bytes: binary },
|
|
{ url: "https://fixture.test/empty.js", bytes: Buffer.alloc(0) },
|
|
]),
|
|
);
|
|
const binaryExport = await exportWebScripts(binaryInput);
|
|
if (!binaryExport.ok) throw binaryExport.error;
|
|
const parsed = webScriptExportResultSchema.parse(
|
|
binaryExport.value.normalized_result,
|
|
);
|
|
const [first, second] = parsed.scripts;
|
|
if (
|
|
first?.content.state !== "exported" ||
|
|
second?.content.state !== "exported" ||
|
|
parsed.analysis_input === null
|
|
)
|
|
throw new Error("Missing exported bytes");
|
|
expect(
|
|
await readFile(
|
|
join(parsed.analysis_input.input_path, first.content.relative_path),
|
|
),
|
|
).toEqual(binary);
|
|
expect(
|
|
await readFile(
|
|
join(parsed.analysis_input.input_path, second.content.relative_path),
|
|
),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("retains missing sources in a manifest without inventing an analysis directory", async () => {
|
|
const capture = scriptScenarioFixture();
|
|
capture.events.items = capture.events.items.filter(
|
|
({ kind }) => kind !== "network-content",
|
|
);
|
|
capture.events.retained = capture.events.items.length;
|
|
const { input } = await setup(capture);
|
|
const result = await exportWebScripts(input);
|
|
if (!result.ok) throw result.error;
|
|
const parsed = webScriptExportResultSchema.parse(
|
|
result.value.normalized_result,
|
|
);
|
|
expect(parsed.analysis_input).toBeNull();
|
|
expect(parsed.scripts[0]?.content.state).toBe("unavailable");
|
|
expect(await readdir(input.output_directory)).toEqual(["manifest.json"]);
|
|
});
|
|
});
|
|
|
|
describe("captured script publication failures and cleanup", () => {
|
|
it("preserves adapter-reported limitations without strengthening their string contract", async () => {
|
|
const { input } = await setup();
|
|
const capture = {
|
|
...selectScriptCapture(scriptScenarioFixture()),
|
|
limitations: ["", "producer-reported limitation"],
|
|
};
|
|
const result = await publishWebScripts(input, capture, "a".repeat(64));
|
|
expect(result.limitations.slice(0, 2)).toEqual(capture.limitations);
|
|
});
|
|
|
|
it.each([
|
|
Buffer.from("{"),
|
|
Buffer.from([255, 254]),
|
|
Buffer.from(JSON.stringify({ unsupported: true })),
|
|
])(
|
|
"rejects malformed capture bytes before creating output",
|
|
async (bytes) => {
|
|
const { input } = await setup();
|
|
await writeFile(input.capture_path, bytes);
|
|
const result = await exportWebScripts(input);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected invalid input");
|
|
expect(result.error._tag).toBe("AnalysisInputError");
|
|
await expect(access(input.output_directory)).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("preserves an existing destination and refuses output through a symlink", async () => {
|
|
const { root, input } = await setup();
|
|
const marker = join(root, "marker.js");
|
|
await writeFile(marker, "keep");
|
|
await symlink(
|
|
root,
|
|
input.output_directory,
|
|
process.platform === "win32" ? "junction" : "dir",
|
|
);
|
|
const result = await exportWebScripts(input);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected exclusive output failure");
|
|
expect(result.error.userMessage).toContain("already exists");
|
|
expect(await readFile(marker, "utf8")).toBe("keep");
|
|
});
|
|
|
|
it("rolls back only the new owned output when durable byte verification fails", async () => {
|
|
const { root, input } = await setup();
|
|
const capture = selectScriptCapture(
|
|
scriptScenarioFixture([
|
|
{ url: "https://fixture.test/a.js", bytes: Buffer.from("first") },
|
|
{ url: "https://fixture.test/b.js", bytes: Buffer.from("second") },
|
|
]),
|
|
);
|
|
const second = capture.scripts[1];
|
|
if (second?.content.state !== "captured") throw new Error("Missing source");
|
|
const broken = {
|
|
...capture,
|
|
scripts: [
|
|
capture.scripts[0],
|
|
{ ...second, content: { ...second.content, sha256: "0".repeat(64) } },
|
|
].filter((value) => value !== undefined),
|
|
};
|
|
await expect(
|
|
publishWebScripts(input, broken, "a".repeat(64)),
|
|
).rejects.toMatchObject({ reason: "integrity" });
|
|
expect(await readdir(root)).toEqual(["capture.json"]);
|
|
});
|
|
|
|
it("cancels before work and rolls back cancellation after output creation", async () => {
|
|
const { root, input } = await setup();
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const result = await exportWebScripts(input, { signal: controller.signal });
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) throw new Error("Expected cancellation");
|
|
expect(result.error._tag).toBe("AnalysisCancelledError");
|
|
await expect(
|
|
publishWebScripts(
|
|
input,
|
|
selectScriptCapture(scriptScenarioFixture()),
|
|
"a".repeat(64),
|
|
controller.signal,
|
|
),
|
|
).rejects.toBeDefined();
|
|
expect(await readdir(root)).toEqual(["capture.json"]);
|
|
});
|
|
|
|
it("identifies a missing capture file and rejected host path syntax", async () => {
|
|
const { input } = await setup();
|
|
const missing = await exportWebScripts({
|
|
...input,
|
|
capture_path: `${input.capture_path}.missing`,
|
|
});
|
|
expect(missing.ok).toBe(false);
|
|
if (missing.ok) throw new Error("Expected unavailable input");
|
|
expect(missing.error.userMessage).toContain(
|
|
`${input.capture_path}.missing`,
|
|
);
|
|
const relative = await exportWebScripts({
|
|
...input,
|
|
capture_path: "capture.json",
|
|
});
|
|
expect(relative.ok).toBe(false);
|
|
if (relative.ok) throw new Error("Expected host path error");
|
|
expect(relative.error._tag).toBe("AnalysisInputError");
|
|
});
|
|
});
|