mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(artifacts): preserve opaque Node SEA asset keys
Separate raw SEA names from safe logical extraction paths. Accept empty asset keys, retain absolute main paths and conflicting source spellings, and assign deterministic paths through the shared artifact path registry. Keep duplicate raw keys malformed and include sourcePath in owned entry equality. Validate 102 focused regressions including compiled CLI and stdio MCP inventory and extraction, the canonical SDK schema case, 9 documentation consistency cases, cached build, typecheck/lint and formatting. A pinned Node-generated preparation blob is verified in a synthetic ELF carrier; real injected native executables and the optional Docker lane were not run.
This commit is contained in:
+18
-8
@@ -17,7 +17,7 @@ Summary delivery retains the complete Evidence: use `parent_evidence_id` with
|
||||
Extraction returns a fresh `output_root`; analyze that extracted directory with
|
||||
`analyze_javascript_application`. Snapshot-only applications have no recovered
|
||||
main script and receive no JavaScript next probe. Neither does a main script
|
||||
whose code path lacks a JavaScript source suffix, such as `main`, because the
|
||||
whose logical path lacks a JavaScript source suffix, such as `main`, because the
|
||||
analyzer selects extracted files by suffix.
|
||||
|
||||
## Recognition and evidence
|
||||
@@ -35,11 +35,18 @@ inventoried once. Native carrier bytes keep their content hash and native
|
||||
classification while the root occurrence represents the SEA container.
|
||||
|
||||
Root `format_metadata` preserves raw flags, decoded flags, code path, blob
|
||||
location, exec arguments and fuse observations. Children retain their role and
|
||||
original `source_path`. The main script uses its declared code path; snapshots
|
||||
and code caches use `__node_sea__/snapshot` and `__node_sea__/code-cache`.
|
||||
Assets retain their names. Extraction applies the shared path-containment and
|
||||
collision rules, rejects unsafe names and never overwrites existing files.
|
||||
location, exec arguments and fuse observations. Main and asset children retain
|
||||
their role and exact declared name in `source_path`. Safe, noncolliding names
|
||||
become logical paths after the shared NFC path normalization. Names that are
|
||||
unsafe as relative paths, have an exact or file-prefix collision with another
|
||||
entry, or occupy the reserved `__node_sea__`
|
||||
namespace receive deterministic logical paths: `__node_sea__/main.cjs` for the
|
||||
main CommonJS source and `__node_sea__/assets/<ordinal>` for assets, using their
|
||||
zero-based order in the blob. The original declared name remains
|
||||
in `source_path`, including an empty asset key. Snapshots and code caches use
|
||||
`__node_sea__/snapshot` and `__node_sea__/code-cache`. Extraction materializes
|
||||
the logical paths through the shared path-containment and collision rules and
|
||||
never overwrites existing files.
|
||||
|
||||
The fuse scan independently records `set`, `unset`, `absent`, or `conflicting`.
|
||||
An unset fuse with a present blob is reported as a limitation. These are static
|
||||
@@ -56,7 +63,7 @@ including the exec-argument extension byte. Older layouts without that byte,
|
||||
Carrier byte order is independent of blob byte order.
|
||||
|
||||
Malformed magic, truncation, unknown flags/extensions, invalid UTF-8, duplicate
|
||||
asset names and conflicting blob declarations fail closed with named reasons.
|
||||
raw asset keys and conflicting blob declarations fail closed with named reasons.
|
||||
Zero padding after a complete blob is allowed. The blob read ceiling is 256 MiB;
|
||||
PE carriers also use the shared 512 MiB input, 65,536 directory-entry and 8 MiB
|
||||
resource-metadata ceilings. The decoder admits at most 100,000 assets whose
|
||||
@@ -74,7 +81,10 @@ code-cache bytes remain opaque.
|
||||
|
||||
Synthetic fixtures cover Mach-O, ELF and PE inventory/extraction, malformed
|
||||
blobs, collisions and fuse contradictions. CLI and MCP tests exercise the same
|
||||
artifact provider and advertised schemas.
|
||||
artifact provider and advertised schemas. A CLI regression also generates a blob
|
||||
with the pinned local Node version, an absolute main path and an empty asset
|
||||
key, then inventories it inside a synthetic ELF carrier. This verifies the real
|
||||
blob producer, not a real injected executable.
|
||||
|
||||
The optional real ELF lane uses Docker's official Node v24.10.0 image and pinned
|
||||
postject to build a fixture, then verifies inventory and extracted script/asset
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
copyArtifactEntry,
|
||||
sameArtifactEntry,
|
||||
type ArtifactEntry,
|
||||
} from "./ArtifactReader.js";
|
||||
|
||||
it("preserves an observed empty source key separately from missing or changed keys", () => {
|
||||
const entry: ArtifactEntry = {
|
||||
path: "__node_sea__/assets/0",
|
||||
sourcePath: "",
|
||||
kind: "file",
|
||||
declaredSize: 5,
|
||||
compressedSize: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
byteOffset: 64,
|
||||
declaredIntegrity: null,
|
||||
unpacked: false,
|
||||
limitations: [],
|
||||
adapterKey: "asset:0",
|
||||
};
|
||||
const copied = copyArtifactEntry(entry);
|
||||
expect(copied.sourcePath).toBe("");
|
||||
expect(sameArtifactEntry(copied, entry)).toBe(true);
|
||||
expect(sameArtifactEntry({ ...copied, sourcePath: "changed" }, entry)).toBe(
|
||||
false,
|
||||
);
|
||||
const { sourcePath: _sourcePath, ...withoutSource } = copied;
|
||||
expect(sameArtifactEntry(withoutSource, entry)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
|
||||
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
|
||||
export interface ArtifactEntry {
|
||||
readonly path: string;
|
||||
/** Exact reader-source spelling when its admitted path omits source syntax. */
|
||||
/** Exact reader-source path or opaque key before logical path mapping. */
|
||||
readonly sourcePath?: string;
|
||||
readonly kind: "file" | "directory" | "symlink" | "slice" | "metadata";
|
||||
readonly declaredSize: number | null;
|
||||
@@ -100,6 +100,7 @@ export const sameArtifactEntry = (
|
||||
produced: ArtifactEntry,
|
||||
): boolean =>
|
||||
entry.path === produced.path &&
|
||||
entry.sourcePath === produced.sourcePath &&
|
||||
entry.kind === produced.kind &&
|
||||
entry.declaredSize === produced.declaredSize &&
|
||||
entry.compressedSize === produced.compressedSize &&
|
||||
|
||||
@@ -424,36 +424,110 @@ it("extracts the main script and assets without executing them", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "traversal", codePath: "../main.js", assets: [] },
|
||||
{
|
||||
name: "duplicate asset",
|
||||
codePath: "main.js",
|
||||
assets: [
|
||||
{ name: "data.txt", body: "a" },
|
||||
{ name: "data.txt", body: "b" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "normalized collision",
|
||||
codePath: "main.js",
|
||||
assets: [
|
||||
{ name: "a/café.txt", body: "a" },
|
||||
{ name: "a/cafe\u0301.txt", body: "b" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "reserved script",
|
||||
codePath: "main.js",
|
||||
assets: [{ name: "main.js", body: "asset" }],
|
||||
},
|
||||
])(
|
||||
"rejects $name before inventory can claim readable contents",
|
||||
async ({ codePath, assets }) => {
|
||||
const path = await writeCarrier("unsafe", nodeSeaElf({ codePath, assets }));
|
||||
await expect(scanCanonicalArtifactInventory(path)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
it("rejects duplicate raw asset keys", async () => {
|
||||
const path = await writeCarrier(
|
||||
"duplicate-assets",
|
||||
nodeSeaElf({
|
||||
assets: [
|
||||
{ name: "data.txt", body: "a" },
|
||||
{ name: "data.txt", body: "b" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await expect(scanCanonicalArtifactInventory(path)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("maps an unsafe main path and opaque asset keys without losing their sources", async () => {
|
||||
const path = await writeCarrier(
|
||||
"opaque-names",
|
||||
nodeSeaElf({
|
||||
codePath: "/absolute/main.js",
|
||||
main: script,
|
||||
assets: [
|
||||
{ name: "", body: "empty" },
|
||||
{ name: "__node_sea__/snapshot", body: "reserved" },
|
||||
{ name: "a/café.txt", body: "composed" },
|
||||
{ name: "a/cafe\u0301.txt", body: "decomposed" },
|
||||
{ name: "tree", body: "parent" },
|
||||
{ name: "tree/child", body: "child" },
|
||||
{ name: "__node_sea__", body: "namespace" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const graph = await scanCanonicalArtifactInventory(path);
|
||||
const rows = graph.occurrences.filter(
|
||||
(row) => row.format_metadata?.kind === "node-sea-entry",
|
||||
);
|
||||
expect(
|
||||
rows
|
||||
.map((row) => [
|
||||
row.logical_path,
|
||||
row.format_metadata?.kind === "node-sea-entry"
|
||||
? row.format_metadata.source_path
|
||||
: null,
|
||||
])
|
||||
.sort(),
|
||||
).toEqual(
|
||||
[
|
||||
["__node_sea__/main.cjs", "/absolute/main.js"],
|
||||
["__node_sea__/assets/0", ""],
|
||||
["__node_sea__/assets/1", "__node_sea__/snapshot"],
|
||||
["a/café.txt", "a/café.txt"],
|
||||
["__node_sea__/assets/3", "a/cafe\u0301.txt"],
|
||||
["tree", "tree"],
|
||||
["__node_sea__/assets/5", "tree/child"],
|
||||
["__node_sea__/assets/6", "__node_sea__"],
|
||||
].sort(),
|
||||
);
|
||||
expect(rows.map((row) => row.source_path).sort()).toEqual(
|
||||
[
|
||||
"/absolute/main.js",
|
||||
"",
|
||||
"__node_sea__/snapshot",
|
||||
"a/café.txt",
|
||||
"a/cafe\u0301.txt",
|
||||
"tree",
|
||||
"tree/child",
|
||||
"__node_sea__",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps a script collision and later file-prefix asset collision", async () => {
|
||||
const graph = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"collisions",
|
||||
nodeSeaElf({
|
||||
codePath: "main.js",
|
||||
assets: [
|
||||
{ name: "main.js", body: "same destination" },
|
||||
{ name: "folder/item", body: "first" },
|
||||
{ name: "folder", body: "later prefix" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const rows = graph.occurrences.filter(
|
||||
(row) => row.format_metadata?.kind === "node-sea-entry",
|
||||
);
|
||||
expect(
|
||||
rows
|
||||
.map((row) => [
|
||||
row.logical_path,
|
||||
row.format_metadata?.kind === "node-sea-entry"
|
||||
? row.format_metadata.source_path
|
||||
: null,
|
||||
])
|
||||
.sort(),
|
||||
).toEqual(
|
||||
[
|
||||
["main.js", "main.js"],
|
||||
["__node_sea__/assets/0", "main.js"],
|
||||
["folder/item", "folder/item"],
|
||||
["__node_sea__/assets/2", "folder"],
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("retains source asset spelling separately from normalized graph paths", async () => {
|
||||
const path = await writeCarrier(
|
||||
|
||||
@@ -6,7 +6,10 @@ import { setImmediate } from "node:timers/promises";
|
||||
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
|
||||
import type { NodeSeaMetadata } from "../domain/artifactFormatMetadata.js";
|
||||
import { artifactIntegrityDetails } from "../domain/artifactIntegrity.js";
|
||||
import { normalizeArtifactPath } from "./ArtifactPaths.js";
|
||||
import {
|
||||
ArtifactPathRegistry,
|
||||
normalizeArtifactPath,
|
||||
} from "./ArtifactPaths.js";
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
copyArtifactEntry,
|
||||
@@ -21,9 +24,7 @@ import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
||||
import {
|
||||
NODE_SEA_BLOB_TOO_LARGE,
|
||||
NODE_SEA_MAX_BLOB_BYTES,
|
||||
NODE_SEA_RESERVED_COLLISION,
|
||||
decodeNodeSeaBlob,
|
||||
nodeSeaAssetCollision,
|
||||
type DecodedNodeSeaBlob,
|
||||
type NodeSeaByteRange,
|
||||
} from "./nodeSea/NodeSeaBlob.js";
|
||||
@@ -45,6 +46,8 @@ import {
|
||||
|
||||
const SNAPSHOT_PATH = "__node_sea__/snapshot";
|
||||
const CODE_CACHE_PATH = "__node_sea__/code-cache";
|
||||
const MAIN_FALLBACK_PATH = "__node_sea__/main.cjs";
|
||||
const ASSET_FALLBACK_PREFIX = "__node_sea__/assets/";
|
||||
|
||||
type NodeSeaRole = "main-script" | "snapshot" | "code-cache" | "asset";
|
||||
|
||||
@@ -311,9 +314,10 @@ export class NodeSeaArtifactReader implements ArtifactReader {
|
||||
formatMetadata: {
|
||||
kind: "node-sea-entry" as const,
|
||||
role: member.role,
|
||||
source_path: member.path,
|
||||
source_path: member.sourcePath,
|
||||
index,
|
||||
},
|
||||
sourcePath: member.sourcePath,
|
||||
} satisfies ArtifactEntry;
|
||||
this.#entries.set(entry.adapterKey, entry);
|
||||
index += 1;
|
||||
@@ -369,46 +373,64 @@ export class NodeSeaArtifactReader implements ArtifactReader {
|
||||
|
||||
function* seaMembers(decoded: DecodedNodeSeaBlob): Generator<{
|
||||
readonly path: string;
|
||||
readonly sourcePath: string;
|
||||
readonly role: NodeSeaRole;
|
||||
readonly range: NodeSeaByteRange;
|
||||
}> {
|
||||
const claimed = new Map<string, NodeSeaRole>();
|
||||
const claim = (path: string, role: NodeSeaRole): string => {
|
||||
const normalized = normalizeArtifactPath(path);
|
||||
const existing = claimed.get(normalized);
|
||||
if (existing !== undefined)
|
||||
const registry = new ArtifactPathRegistry();
|
||||
const claimed = new Set<string>();
|
||||
const reserve = (path: string): string => {
|
||||
if (claimed.has(path))
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
role === "asset" && existing === "asset"
|
||||
? nodeSeaAssetCollision(normalized)
|
||||
: role === "asset" || existing === "asset"
|
||||
? NODE_SEA_RESERVED_COLLISION
|
||||
: `Node SEA entry path collides: ${normalized}`,
|
||||
"path",
|
||||
`Node SEA entry path collides: ${path}`,
|
||||
);
|
||||
claimed.set(normalized, role);
|
||||
registry.add(path, "file");
|
||||
claimed.add(path);
|
||||
return path;
|
||||
};
|
||||
const mappedPath = (raw: string, fallback: string): string => {
|
||||
try {
|
||||
const normalized = normalizeArtifactPath(raw);
|
||||
if (
|
||||
normalized !== "__node_sea__" &&
|
||||
!normalized.startsWith("__node_sea__/")
|
||||
)
|
||||
return reserve(normalized);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof ArtifactReaderFailure) || cause.reason !== "path")
|
||||
throw cause;
|
||||
}
|
||||
return reserve(fallback);
|
||||
};
|
||||
if (decoded.mainScript !== null)
|
||||
yield {
|
||||
path: claim(decoded.codePath, "main-script"),
|
||||
path: mappedPath(decoded.codePath, MAIN_FALLBACK_PATH),
|
||||
sourcePath: decoded.codePath,
|
||||
role: "main-script",
|
||||
range: decoded.mainScript,
|
||||
};
|
||||
if (decoded.snapshot !== null)
|
||||
yield {
|
||||
path: claim(SNAPSHOT_PATH, "snapshot"),
|
||||
path: reserve(SNAPSHOT_PATH),
|
||||
sourcePath: SNAPSHOT_PATH,
|
||||
role: "snapshot",
|
||||
range: decoded.snapshot,
|
||||
};
|
||||
if (decoded.codeCache !== null)
|
||||
yield {
|
||||
path: claim(CODE_CACHE_PATH, "code-cache"),
|
||||
path: reserve(CODE_CACHE_PATH),
|
||||
sourcePath: CODE_CACHE_PATH,
|
||||
role: "code-cache",
|
||||
range: decoded.codeCache,
|
||||
};
|
||||
for (const asset of decoded.assets)
|
||||
for (const [ordinal, asset] of decoded.assets.entries())
|
||||
yield {
|
||||
path: claim(asset.name, "asset"),
|
||||
path: mappedPath(
|
||||
asset.name,
|
||||
`${ASSET_FALLBACK_PREFIX}${String(ordinal)}`,
|
||||
),
|
||||
sourcePath: asset.name,
|
||||
role: "asset",
|
||||
range: asset.range,
|
||||
};
|
||||
|
||||
@@ -84,6 +84,13 @@ it("rejects colliding asset names", async () => {
|
||||
).rejects.toThrow(nodeSeaAssetCollision("shared"));
|
||||
});
|
||||
|
||||
it("retains an empty asset key as a raw Node SEA key", async () => {
|
||||
const decoded = await decodeNodeSeaBlob(
|
||||
buildNodeSeaBlob({ assets: [{ name: "", body: "value" }] }),
|
||||
);
|
||||
expect(decoded.assets).toMatchObject([{ name: "", range: { length: 5 } }]);
|
||||
});
|
||||
|
||||
const limitOf = async (bytes: Buffer): Promise<ArtifactReaderFailure> => {
|
||||
try {
|
||||
await decodeNodeSeaBlob(bytes);
|
||||
|
||||
@@ -37,8 +37,6 @@ export const NODE_SEA_UNKNOWN_FLAGS =
|
||||
export const NODE_SEA_UNKNOWN_EXTENSION =
|
||||
"Node SEA exec argv extension is unknown";
|
||||
export const NODE_SEA_TRAILING_BYTES = "Node SEA blob has trailing bytes";
|
||||
export const NODE_SEA_RESERVED_COLLISION =
|
||||
"Node SEA asset name collides with a reserved entry";
|
||||
export const NODE_SEA_DUPLICATE_BLOB =
|
||||
"Node SEA blob is declared more than once";
|
||||
export const NODE_SEA_ELF_DESCRIPTOR_TRUNCATED =
|
||||
@@ -56,7 +54,6 @@ export const NODE_SEA_FUSE_UNSET =
|
||||
export const NODE_SEA_FUSE_CONFLICT = "Node SEA fuse sentinels disagree";
|
||||
export const NODE_SEA_EMPTY_CODE_PATH = "Node SEA code path is empty";
|
||||
export const NODE_SEA_INVALID_UTF8 = "Node SEA string is not valid UTF-8";
|
||||
export const NODE_SEA_EMPTY_ASSET_NAME = "Node SEA asset name is empty";
|
||||
export const NODE_SEA_ASSET_COUNT = `Node SEA asset count exceeds the ${String(MAX_ASSET_COUNT)}-asset bound. ${NO_OVERRIDE}`;
|
||||
export const NODE_SEA_ASSET_NAME_BYTES = `Node SEA asset names exceed the ${String(MAX_ASSET_NAME_BYTES)}-byte name budget. ${NO_OVERRIDE}`;
|
||||
export const NODE_SEA_EXEC_ARGV_COUNT = `Node SEA exec argv count exceeds the ${String(MAX_EXEC_ARGV)}-argument bound. ${NO_OVERRIDE}`;
|
||||
@@ -276,7 +273,7 @@ class BlobCursor {
|
||||
await setImmediate();
|
||||
assertArtifactReadActive(signal);
|
||||
}
|
||||
const name = this.text(false, NODE_SEA_EMPTY_ASSET_NAME, charge);
|
||||
const name = this.text(true, NODE_SEA_INVALID_UTF8, charge);
|
||||
if (seen.has(name)) throw formatFailure(nodeSeaAssetCollision(name));
|
||||
seen.add(name);
|
||||
assets.push({ name, range: this.byteRange() });
|
||||
|
||||
@@ -152,8 +152,8 @@ export const artifactOccurrenceSchema = z.object({
|
||||
unpacked: z.literal(true).exactOptional(),
|
||||
hash_status: z.enum(["verified", "mismatched", "not-hashed", "unavailable"]),
|
||||
source_location: artifactByteRangeSchema.nullable(),
|
||||
/** Original path within this occurrence's reader when normalization changed its spelling. */
|
||||
source_path: z.string().min(1).exactOptional(),
|
||||
/** Exact reader-source path or opaque key, including an observed empty key. */
|
||||
source_path: z.string().exactOptional(),
|
||||
declared_integrity: artifactIntegrityClaimSchema.exactOptional(),
|
||||
format_metadata: artifactFormatMetadataSchema.exactOptional(),
|
||||
limitations: z.array(z.string()),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
@@ -83,6 +83,183 @@ cliTest.for(["elf", "mach-o", "pe"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"reads a real Node-produced SEA blob with an absolute main and empty asset key",
|
||||
async ({ cli, processes }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-node-producer-");
|
||||
const mainPath = join(root, "main.cjs");
|
||||
const assetPath = join(root, "asset.txt");
|
||||
const blobPath = join(root, "preparation.blob");
|
||||
const configPath = join(root, "sea-config.json");
|
||||
const main = 'throw new Error("must not execute");\n';
|
||||
await writeFile(mainPath, main);
|
||||
await writeFile(assetPath, "empty-key asset");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
main: mainPath,
|
||||
output: blobPath,
|
||||
useSnapshot: false,
|
||||
useCodeCache: false,
|
||||
assets: { "": assetPath },
|
||||
}),
|
||||
);
|
||||
const generated = await processes.run(process.execPath, [
|
||||
"--experimental-sea-config",
|
||||
configPath,
|
||||
]);
|
||||
expect(generated.exitCode, generated.stdout + generated.stderr).toBe(0);
|
||||
const source = join(root, "application");
|
||||
// Node produces the blob; only its ELF carrier is synthetic.
|
||||
await writeFile(source, nodeSeaElf({ blob: await readFile(blobPath) }));
|
||||
const inspected = await cli.run({
|
||||
arguments: ["inspect-artifact", source, "--json"],
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0);
|
||||
const inspection = artifactInspectionResultSchema.parse(
|
||||
parseEvidence(inspected.json).normalized_result,
|
||||
);
|
||||
const graph = artifactInventoryResultSchema.parse(
|
||||
inspection.substeps[0]?.evidence.normalized_result,
|
||||
);
|
||||
expect(graph.coverage.status).toBe("complete");
|
||||
expect(
|
||||
graph.occurrences.find(
|
||||
(row) => row.logical_path === "__node_sea__/main.cjs",
|
||||
),
|
||||
).toMatchObject({
|
||||
source_path: mainPath,
|
||||
artifact_format: "javascript-bundle",
|
||||
});
|
||||
expect(
|
||||
graph.occurrences.find(
|
||||
(row) => row.logical_path === "__node_sea__/assets/0",
|
||||
),
|
||||
).toMatchObject({ source_path: "", format_metadata: { source_path: "" } });
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"preserves opaque SEA source keys through CLI and MCP inventory and extraction",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-opaque-public-");
|
||||
const source = join(root, "application");
|
||||
const main = Buffer.from("void 0;\n");
|
||||
const assets = [
|
||||
{ name: "", body: "empty key" },
|
||||
{ name: "../outside.txt", body: "parent key" },
|
||||
{ name: "safe/data.txt", body: "safe key" },
|
||||
{ name: "__node_sea__/main.cjs", body: "reserved key" },
|
||||
{ name: "data/café.txt", body: "composed key" },
|
||||
{ name: "data/cafe\u0301.txt", body: "decomposed key" },
|
||||
];
|
||||
// The SEA blob follows Node's format; nodeSeaElf supplies a synthetic carrier.
|
||||
await writeFile(
|
||||
source,
|
||||
nodeSeaElf({
|
||||
blob: buildNodeSeaBlob({
|
||||
codePath: "/generated/main.cjs",
|
||||
main,
|
||||
assets,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const expected = [
|
||||
{
|
||||
path: "__node_sea__/main.cjs",
|
||||
source: "/generated/main.cjs",
|
||||
body: main,
|
||||
},
|
||||
{
|
||||
path: "__node_sea__/assets/0",
|
||||
source: "",
|
||||
body: Buffer.from("empty key"),
|
||||
},
|
||||
{
|
||||
path: "__node_sea__/assets/1",
|
||||
source: "../outside.txt",
|
||||
body: Buffer.from("parent key"),
|
||||
},
|
||||
{
|
||||
path: "safe/data.txt",
|
||||
source: "safe/data.txt",
|
||||
body: Buffer.from("safe key"),
|
||||
},
|
||||
{
|
||||
path: "__node_sea__/assets/3",
|
||||
source: "__node_sea__/main.cjs",
|
||||
body: Buffer.from("reserved key"),
|
||||
},
|
||||
{
|
||||
path: "data/café.txt",
|
||||
source: "data/café.txt",
|
||||
body: Buffer.from("composed key"),
|
||||
},
|
||||
{
|
||||
path: "__node_sea__/assets/5",
|
||||
source: "data/cafe\u0301.txt",
|
||||
body: Buffer.from("decomposed key"),
|
||||
},
|
||||
];
|
||||
const checkInventory = (inspection: unknown): void => {
|
||||
const parsed = artifactInspectionResultSchema.parse(inspection);
|
||||
const graph = artifactInventoryResultSchema.parse(
|
||||
parsed.substeps[0]?.evidence.normalized_result,
|
||||
);
|
||||
expect(graph.manifest.root_format).toBe("node-sea");
|
||||
expect(graph.coverage.status).toBe("complete");
|
||||
for (const { path, source: raw } of expected) {
|
||||
const row = graph.occurrences.find(
|
||||
(item) => item.logical_path === path,
|
||||
);
|
||||
expect(row?.source_path).toBe(raw);
|
||||
expect(row?.format_metadata).toMatchObject({ source_path: raw });
|
||||
expect(row?.hash_status).toBe("verified");
|
||||
}
|
||||
};
|
||||
const checkExtraction = async (extraction: unknown): Promise<void> => {
|
||||
const result = artifactExtractionResultSchema.parse(extraction);
|
||||
onTestFinished(() =>
|
||||
rm(result.output_root, { recursive: true, force: true }),
|
||||
);
|
||||
expect(result.containment_verified).toBe(true);
|
||||
expect(result.artifacts.map((item) => item.relative_path).sort()).toEqual(
|
||||
expected.map((item) => item.path).sort(),
|
||||
);
|
||||
for (const { path, body } of expected)
|
||||
expect(await readFile(join(result.output_root, path))).toEqual(body);
|
||||
};
|
||||
|
||||
const inspected = await cli.run({
|
||||
arguments: ["inspect-artifact", source, "--json"],
|
||||
});
|
||||
expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0);
|
||||
checkInventory(parseEvidence(inspected.json).normalized_result);
|
||||
const extracted = await cli.run({
|
||||
arguments: ["extract-artifact", source, "--json"],
|
||||
environment: { TMPDIR: root, TMP: root, TEMP: root },
|
||||
});
|
||||
expect(extracted.exitCode, extracted.stdout + extracted.stderr).toBe(0);
|
||||
await checkExtraction(parseEvidence(extracted.json).normalized_result);
|
||||
|
||||
const call = await cli.sharedMcpCall?.();
|
||||
if (call === undefined) throw new Error("Expected a stdio MCP session");
|
||||
const opened = await call("open_binary", { path: source });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const mcpInspected = await call("inspect_artifact", {});
|
||||
expect(mcpInspected.isError, JSON.stringify(mcpInspected)).not.toBe(true);
|
||||
checkInventory(
|
||||
parseEvidence(mcpInspected.structuredContent).normalized_result,
|
||||
);
|
||||
const mcpExtracted = await call("extract_artifact", {});
|
||||
expect(mcpExtracted.isError, JSON.stringify(mcpExtracted)).not.toBe(true);
|
||||
await checkExtraction(
|
||||
parseEvidence(mcpExtracted.structuredContent).normalized_result,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"offers no JavaScript probe for a main script without a source suffix",
|
||||
async ({ cli }) => {
|
||||
|
||||
Reference in New Issue
Block a user