mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
425776174c
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.
253 lines
7.4 KiB
TypeScript
253 lines
7.4 KiB
TypeScript
import { setImmediate } from "node:timers/promises";
|
|
|
|
import { expect, it } from "vitest";
|
|
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import {
|
|
NODE_SEA_ASSET_COUNT,
|
|
NODE_SEA_ASSET_NAME_BYTES,
|
|
NODE_SEA_BAD_MAGIC,
|
|
NODE_SEA_EMPTY_CODE_PATH,
|
|
NODE_SEA_FUSE_CONFLICT,
|
|
NODE_SEA_FUSE_ABSENT,
|
|
NODE_SEA_FUSE_UNSET,
|
|
NODE_SEA_MAGIC,
|
|
NODE_SEA_TRAILING_BYTES,
|
|
NODE_SEA_TRUNCATED,
|
|
NODE_SEA_UNKNOWN_EXTENSION,
|
|
NODE_SEA_UNKNOWN_FLAGS,
|
|
decodeNodeSeaBlob,
|
|
nodeSeaAssetCollision,
|
|
observeNodeSeaFuseMatches,
|
|
} from "./NodeSeaBlob.js";
|
|
import { buildNodeSeaBlob } from "./NodeSea.fixture.js";
|
|
|
|
const header = (flags: number, extension = 1): Buffer => {
|
|
const bytes = Buffer.alloc(9);
|
|
bytes.writeUInt32LE(NODE_SEA_MAGIC, 0);
|
|
bytes.writeUInt32LE(flags, 4);
|
|
bytes[8] = extension;
|
|
return bytes;
|
|
};
|
|
|
|
it("rejects a blob that ends before the magic", async () => {
|
|
await expect(decodeNodeSeaBlob(Buffer.from([0x20, 0xda]))).rejects.toThrow(
|
|
NODE_SEA_TRUNCATED,
|
|
);
|
|
});
|
|
|
|
it("rejects a blob whose magic is not 0x143da20", async () => {
|
|
await expect(decodeNodeSeaBlob(Buffer.alloc(9))).rejects.toThrow(
|
|
NODE_SEA_BAD_MAGIC,
|
|
);
|
|
});
|
|
|
|
it("rejects a header that ends before the extension byte", async () => {
|
|
await expect(decodeNodeSeaBlob(header(0).subarray(0, 8))).rejects.toThrow(
|
|
NODE_SEA_TRUNCATED,
|
|
);
|
|
});
|
|
|
|
it("rejects unknown flag bits", async () => {
|
|
await expect(decodeNodeSeaBlob(header(1 << 5))).rejects.toThrow(
|
|
NODE_SEA_UNKNOWN_FLAGS,
|
|
);
|
|
});
|
|
|
|
it("rejects an unknown exec argv extension", async () => {
|
|
await expect(decodeNodeSeaBlob(header(0, 9))).rejects.toThrow(
|
|
NODE_SEA_UNKNOWN_EXTENSION,
|
|
);
|
|
});
|
|
|
|
it("rejects an empty code path", async () => {
|
|
await expect(
|
|
decodeNodeSeaBlob(buildNodeSeaBlob({ codePath: "" })),
|
|
).rejects.toThrow(NODE_SEA_EMPTY_CODE_PATH);
|
|
});
|
|
|
|
it("rejects a truncated length-prefixed field", async () => {
|
|
const bytes = Buffer.concat([header(0), Buffer.alloc(4)]);
|
|
await expect(decodeNodeSeaBlob(bytes)).rejects.toThrow(NODE_SEA_TRUNCATED);
|
|
});
|
|
|
|
it("rejects colliding asset names", async () => {
|
|
await expect(
|
|
decodeNodeSeaBlob(
|
|
buildNodeSeaBlob({
|
|
assets: [
|
|
{ name: "shared", body: "one" },
|
|
{ name: "shared", body: "two" },
|
|
],
|
|
}),
|
|
),
|
|
).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);
|
|
} catch (cause) {
|
|
if (cause instanceof ArtifactReaderFailure) return cause;
|
|
throw cause;
|
|
}
|
|
throw new Error("expected a limit failure");
|
|
};
|
|
|
|
it("refuses an asset table over its count bound before creating records", async () => {
|
|
const prefix = buildNodeSeaBlob({ assets: [{ name: "a", body: "" }] });
|
|
const count = Buffer.alloc(8);
|
|
count.writeBigUInt64LE(100_001n);
|
|
// Replace the declared count; the bound is checked before the table length.
|
|
const bytes = Buffer.concat([prefix.subarray(0, -25), count]);
|
|
const failure = await limitOf(bytes);
|
|
expect(failure).toMatchObject({
|
|
reason: "limit",
|
|
message: NODE_SEA_ASSET_COUNT,
|
|
});
|
|
});
|
|
|
|
it("refuses asset names over the decoded name budget", async () => {
|
|
const name = "n".repeat(4 * 1024 * 1024 + 1);
|
|
const failure = await limitOf(
|
|
buildNodeSeaBlob({
|
|
assets: [
|
|
{ name: `a${name}`, body: "" },
|
|
{ name: `b${name}`, body: "" },
|
|
],
|
|
}),
|
|
);
|
|
expect(failure).toMatchObject({
|
|
reason: "limit",
|
|
message: NODE_SEA_ASSET_NAME_BYTES,
|
|
});
|
|
});
|
|
|
|
it("rejects non-zero trailing bytes and keeps zero padding", async () => {
|
|
const padded = buildNodeSeaBlob({ trailing: Buffer.alloc(8) });
|
|
expect((await decodeNodeSeaBlob(padded)).codePath).toBe("sea.js");
|
|
const dirty = Buffer.concat([padded, Buffer.from([1])]);
|
|
await expect(decodeNodeSeaBlob(dirty)).rejects.toThrow(
|
|
NODE_SEA_TRAILING_BYTES,
|
|
);
|
|
});
|
|
|
|
it("decodes flags, the main script, code cache, assets, and exec argv", async () => {
|
|
const decoded = await decodeNodeSeaBlob(
|
|
buildNodeSeaBlob({
|
|
codePath: "app/main.js",
|
|
main: "console.log(1)\n",
|
|
codeCache: Buffer.from([1, 2, 3]),
|
|
assets: [{ name: "pkg/data.txt", body: "asset" }],
|
|
execArgv: ["--no-warnings", ""],
|
|
disableExperimentalSeaWarning: true,
|
|
execArgvExtension: 2,
|
|
}),
|
|
);
|
|
expect(decoded).toMatchObject({
|
|
flags: (1 << 0) | (1 << 2) | (1 << 3) | (1 << 4),
|
|
disableExperimentalSeaWarning: true,
|
|
useSnapshot: false,
|
|
useCodeCache: true,
|
|
includeAssets: true,
|
|
includeExecArgv: true,
|
|
execArgvExtension: "cli",
|
|
codePath: "app/main.js",
|
|
execArgv: ["--no-warnings", ""],
|
|
assets: [{ name: "pkg/data.txt" }],
|
|
});
|
|
expect(decoded.mainScript?.length).toBe(
|
|
Buffer.byteLength("console.log(1)\n"),
|
|
);
|
|
expect(decoded.snapshot).toBeNull();
|
|
expect(decoded.codeCache?.length).toBe(3);
|
|
});
|
|
|
|
it("treats the body as a snapshot when that flag is set", async () => {
|
|
const decoded = await decodeNodeSeaBlob(
|
|
buildNodeSeaBlob({
|
|
codePath: "sea.js",
|
|
snapshot: Buffer.from("snap"),
|
|
}),
|
|
);
|
|
expect(decoded.useSnapshot).toBe(true);
|
|
expect(decoded.mainScript).toBeNull();
|
|
expect(decoded.snapshot?.length).toBe(4);
|
|
});
|
|
|
|
it("reports an absent, unset, set, or conflicting fuse sentinel", () => {
|
|
expect(observeNodeSeaFuseMatches([])).toMatchObject({
|
|
fuseState: "absent",
|
|
fuseSentinel: null,
|
|
fuseContradiction: NODE_SEA_FUSE_ABSENT,
|
|
});
|
|
const unset = `NODE_SEA_FUSE_${"a".repeat(32)}:0`;
|
|
expect(observeNodeSeaFuseMatches([unset, unset])).toMatchObject({
|
|
fuseState: "unset",
|
|
fuseSentinel: unset,
|
|
fuseContradiction: NODE_SEA_FUSE_UNSET,
|
|
});
|
|
const set = `NODE_SEA_FUSE_${"b".repeat(32)}:1`;
|
|
expect(observeNodeSeaFuseMatches([set])).toMatchObject({
|
|
fuseState: "set",
|
|
fuseSentinel: set,
|
|
fuseContradiction: null,
|
|
});
|
|
expect(observeNodeSeaFuseMatches([unset, set])).toMatchObject({
|
|
fuseState: "conflicting",
|
|
fuseSentinel: null,
|
|
fuseContradiction: NODE_SEA_FUSE_CONFLICT,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"assets",
|
|
() =>
|
|
buildNodeSeaBlob({
|
|
assets: Array.from({ length: 1024 }, (_, index) => ({
|
|
name: `asset-${String(index)}`,
|
|
body: "",
|
|
})),
|
|
}),
|
|
],
|
|
[
|
|
"exec arguments",
|
|
() =>
|
|
buildNodeSeaBlob({
|
|
execArgv: Array.from({ length: 1024 }, () => "--no-warnings"),
|
|
}),
|
|
],
|
|
[
|
|
"zero padding",
|
|
() => buildNodeSeaBlob({ trailing: Buffer.alloc(1024 * 1024) }),
|
|
],
|
|
] as const)("observes cancellation while decoding %s", async (_name, build) => {
|
|
const bytes = build();
|
|
const controller = new AbortController();
|
|
const reason = { stage: "decoding" };
|
|
const pending = decodeNodeSeaBlob(bytes, controller.signal);
|
|
// Let a batch run; cancellation must be observed before the whole table or
|
|
// padding scan finishes, without relying on elapsed-time assertions.
|
|
await setImmediate();
|
|
controller.abort(reason);
|
|
await expect(pending).rejects.toMatchObject({
|
|
reason: "cancelled",
|
|
cause: reason,
|
|
});
|
|
});
|
|
|
|
it("preserves cancellation before decoding even a malformed blob", async () => {
|
|
const reason = { stage: "before decoding" };
|
|
await expect(
|
|
decodeNodeSeaBlob(Buffer.alloc(0), AbortSignal.abort(reason)),
|
|
).rejects.toMatchObject({ reason: "cancelled", cause: reason });
|
|
});
|