Files
rea/src/browser/assets/PublishWebScripts.ts
T
b4db35f092 fix: bind identity and coverage to the producing rule (#1513)
* fix: bind identity and coverage to the producing rule

Logical paths, container families, examined bytes, path-segment conventions, destination collisions, file-id leases, Mach-O CPU selection, and firmware cleanup now follow the observation that owns each rule.

* fix: fail unmatched Mach-O slices and keep firmware roots independent

A fat target whose architecture lipo did not list now stops before slice tools run. Chained fixups still count unread LC_DYLD_INFO rebase bytes, and a firmware cleanup failure no longer blocks the next workspace.

* test: cover path identity and runtime families at the projection boundary

NFC joins and segment conventions are now asserted by the path resolver, the Apple projection, and a real APK inventory instead of helper unit tests.

* test: expect unread dyld rebase bytes in the Apple dispatch fixture

The legacy LC_DYLD_INFO fixture keeps a rebase range this reader does not decode, so the public CLI and MCP check now requires that partial coverage beside the bind opcodes.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-10-10 17:14:47 +08:00

151 lines
6.0 KiB
TypeScript

import { createHash } from "node:crypto";
import { join } from "node:path";
import { Readable } from "node:stream";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import {
webScriptExportManifestSchema,
webScriptExportResultSchema,
type ExportWebScriptsInput,
type ExportedWebScript,
type WebScriptExportResult,
} from "../../domain/webScriptExport.js";
import {
planWebScriptExport,
type PlannedWebScript,
} from "../../domain/webScriptExportPlan.js";
import { WebScriptExportError } from "../../domain/webScriptExportError.js";
import type { SelectedScriptCapture } from "./ScriptCaptureAdapters.js";
const LIMITATIONS = [
"Only script bytes retained in this capture are exported. Sources are not refetched or executed, and a response or Debugger source does not prove execution.",
"Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.",
"Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.",
"Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.",
"Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.",
];
/** Exclusively publish verified bytes and a manifest, rolling back partial work. */
export const publishWebScripts = async (
input: ExportWebScriptsInput,
capture: SelectedScriptCapture & { readonly sourceEvidenceId: string | null },
captureSha256: string,
signal?: AbortSignal,
): Promise<WebScriptExportResult> => {
const planned = planWebScriptExport(capture.scripts);
const tree = await SafeOutputTree.create(input.output_directory);
try {
signal?.throwIfAborted();
const records: ExportedWebScript[] = [];
for (const [index, item] of planned.entries()) {
signal?.throwIfAborted();
records.push(await publishPlannedScript(tree, item, index, signal));
}
const manifest = webScriptExportManifestSchema.parse({
capture_path: input.capture_path,
capture_sha256: captureSha256,
capture_kind: capture.kind,
source_evidence_id: capture.sourceEvidenceId,
capture_completeness: capture.completeness,
output_directory: tree.outputRoot,
analysis_input: records.some(
({ content }) => content.state === "exported",
)
? { input_path: join(tree.outputRoot, "files"), format: "directory" }
: null,
scripts: records,
limitations: [
...capture.limitations,
...LIMITATIONS,
...(records.some(({ content }) => content.state === "exported")
? []
: [
"No script bytes were exportable. Include script sources in inspect_web_page or select response_body in capture_browser_scenario, then capture again.",
]),
],
});
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const digest = createHash("sha256").update(bytes).digest("hex");
const result = webScriptExportResultSchema.parse({
...manifest,
manifest: {
path: join(tree.outputRoot, "manifest.json"),
sha256: digest,
bytes: bytes.length,
},
});
await tree.write("manifest.json", Readable.from([bytes]), digest, signal);
signal?.throwIfAborted();
await tree.commit();
return result;
} catch (cause: unknown) {
try {
await tree.rollback();
} catch (cleanupCause: unknown) {
throw new WebScriptExportError(
"io",
tree.outputRoot,
`Publication failed (${message(cause)}); rollback failed (${message(cleanupCause)}). Remove the residual output directory before retrying.`,
[tree.outputRoot],
);
}
throw cause;
}
};
const publishPlannedScript = async (
tree: SafeOutputTree,
item: PlannedWebScript,
index: number,
signal: AbortSignal | undefined,
): Promise<ExportedWebScript> => {
const { script, record } = item;
const content = script.content;
if (content.state !== "captured" || record.content.state !== "exported")
return record;
const write = (relativePath: string): Promise<unknown> =>
tree.write(
`files/${relativePath}`,
Readable.from([content.bytes]),
content.sha256,
signal,
);
try {
await write(record.content.relative_path);
return record;
} catch (cause: unknown) {
const collision = destinationCollisionReason(cause);
if (collision === undefined || record.content.layout !== "url-path")
throw cause;
const relativePath = isolatedScriptPath(script, index);
await write(relativePath);
return {
...record,
content: {
...record.content,
relative_path: relativePath,
layout: "isolated",
layout_reason: collision,
},
};
}
};
const isolatedScriptPath = (
script: PlannedWebScript["script"],
index: number,
): string =>
`isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`;
const destinationCollisionReason = (cause: unknown): string | undefined =>
cause instanceof ArtifactReaderFailure &&
cause.reason === "path" &&
cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX)
? cause.message
: undefined;
const message = (cause: unknown): string =>
cause instanceof Error ? cause.message : "Unknown publication failure";