mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -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)
146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import type {
|
|
CapturedWebScript,
|
|
ExportedWebScript,
|
|
} from "./webScriptExport.js";
|
|
|
|
const hash = (text: string): string =>
|
|
createHash("sha256").update(text).digest("hex");
|
|
const PORTABLE_SEGMENT = /^[A-Za-z0-9_~.-]+$/u;
|
|
const RESERVED_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu;
|
|
|
|
/** One planned file, or an explicit missing source with no filesystem effect. */
|
|
export interface PlannedWebScript {
|
|
readonly script: CapturedWebScript;
|
|
readonly record: ExportedWebScript;
|
|
}
|
|
|
|
/** Preserve safe URL layouts only when every captured path is unambiguous. */
|
|
export const planWebScriptExport = (
|
|
scripts: readonly CapturedWebScript[],
|
|
): readonly PlannedWebScript[] => {
|
|
const candidates = scripts.map(candidatePath);
|
|
const conflicts = conflictingPaths(candidates);
|
|
return scripts.map((script, index) => {
|
|
if (script.content.state === "unavailable")
|
|
return {
|
|
script,
|
|
record: {
|
|
source: script.source,
|
|
url: script.url,
|
|
content: script.content,
|
|
},
|
|
};
|
|
const candidate = candidates[index];
|
|
const reason = conflicts.has(index)
|
|
? "Captured paths collide by content version, case, or file/directory prefix."
|
|
: (candidate?.reason ?? null);
|
|
const path = reason === null ? candidate?.path : undefined;
|
|
return {
|
|
script,
|
|
record: {
|
|
source: script.source,
|
|
url: script.url,
|
|
content: {
|
|
state: "exported",
|
|
relative_path:
|
|
path ??
|
|
`isolated/source-${index + 1}-${hash(JSON.stringify(script.source))}.js`,
|
|
layout: path === undefined ? "isolated" : "url-path",
|
|
layout_reason: reason,
|
|
sha256: script.content.sha256,
|
|
bytes: script.content.bytes.length,
|
|
media_type: script.content.media_type,
|
|
redacted: script.content.redacted,
|
|
representation: script.content.representation,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
};
|
|
|
|
interface CandidatePath {
|
|
readonly path?: string;
|
|
readonly reason: string | null;
|
|
}
|
|
|
|
const candidatePath = (script: CapturedWebScript): CandidatePath => {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(script.url);
|
|
} catch {
|
|
return {
|
|
reason: "Source has no absolute HTTP URL (for example an inline script).",
|
|
};
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
return { reason: "Source URL scheme has no local module layout mapping." };
|
|
const parts = url.pathname.slice(1).split("/");
|
|
if (
|
|
!/\.(?:js|mjs|cjs)$/u.test(url.pathname) ||
|
|
parts.some(
|
|
(part) =>
|
|
!PORTABLE_SEGMENT.test(part) ||
|
|
part === "." ||
|
|
part === ".." ||
|
|
part.endsWith(".") ||
|
|
RESERVED_SEGMENT.test(part),
|
|
)
|
|
)
|
|
return {
|
|
reason: "URL path is not an ordinary portable JavaScript module path.",
|
|
};
|
|
return {
|
|
path: `origins/${hash(url.origin)}/${parts.join("/")}`,
|
|
reason:
|
|
url.search !== "" ||
|
|
url.hash !== "" ||
|
|
url.username !== "" ||
|
|
url.password !== ""
|
|
? "URL query, fragment, or credentials cannot select a canonical local module."
|
|
: null,
|
|
};
|
|
};
|
|
|
|
// Register every prefix so case collisions in parent directories and a file
|
|
// conflicting with a directory isolate all affected sources, not just the last.
|
|
const conflictingPaths = (
|
|
candidates: readonly CandidatePath[],
|
|
): Set<number> => {
|
|
const prefixes = new Map<
|
|
string,
|
|
{
|
|
spellings: Set<string>;
|
|
indices: number[];
|
|
files: number[];
|
|
}
|
|
>();
|
|
candidates.forEach(({ path }, index) => {
|
|
if (path === undefined) return;
|
|
const parts = path.split("/");
|
|
parts.forEach((_, position) => {
|
|
const spelling = parts.slice(0, position + 1).join("/");
|
|
const key = spelling.toLowerCase();
|
|
const entry = prefixes.get(key) ?? {
|
|
spellings: new Set<string>(),
|
|
indices: [],
|
|
files: [],
|
|
};
|
|
entry.spellings.add(spelling);
|
|
entry.indices.push(index);
|
|
if (position === parts.length - 1) entry.files.push(index);
|
|
prefixes.set(key, entry);
|
|
});
|
|
});
|
|
const conflicts = new Set<number>();
|
|
for (const entry of prefixes.values())
|
|
if (
|
|
entry.spellings.size > 1 ||
|
|
entry.files.length > 1 ||
|
|
(entry.files.length > 0 && entry.indices.length > entry.files.length)
|
|
)
|
|
for (const index of entry.indices) conflicts.add(index);
|
|
return conflicts;
|
|
};
|