mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(artifacts): inventory and extract Node.js single executable application blobs (#7000)
* feat(artifacts): inventory and extract Node.js single executable application blobs Adds a node-sea artifact reader that locates the NODE_SEA_BLOB payload in Mach-O sections, ELF notes and PE RCDATA resources, decodes the blob header, main script, optional snapshot and code cache, and named assets, and extracts their stored bytes through the existing safe output tree. The executable is never run. Resource-tree errors in a PE without a NODE_SEA_BLOB name stay with native classification; truncated, unknown or over-limit SEA declarations fail closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe * fix(artifacts): reuse PE resource facts and bound SEA asset tables Move the bounded NODE_SEA_BLOB name probe into the shared PE resource layout module so SEA admission uses the same header and section mapping as the full parser; the layout reader now accepts a header prefix with the file size. Bound SEA asset count and decoded name bytes before records are created and report resource bounds as limit failures. Offer the JavaScript next probe only for main scripts with a source suffix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016y53PdpyMAp9RSW65fatHe * fix(artifacts): make SEA metadata construction cancellable --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
26 files changed
+3151
-17
No files matched your search
+10
@@ -236,6 +236,16 @@ Formats 2, 3 and 4 retain header, directory and integrity facts. Compact deliver
|
||||
keeps complete Evidence in the requested bundle. See [Godot PCK](godot-pck.md)
|
||||
for embedded packs, batch queries and unknown encrypted coverage.
|
||||
|
||||
Inspect a Node.js SEA carrier without running it:
|
||||
|
||||
```sh
|
||||
rea inspect-artifact ./application --json
|
||||
rea extract-artifact ./application --json
|
||||
```
|
||||
|
||||
See [Node.js SEA](node-sea.md) for supported layouts, script and asset paths,
|
||||
fuse observations, and the real-fixture verification lane.
|
||||
|
||||
Inspect local model declarations without loading weights:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Node.js single executable applications
|
||||
|
||||
REA inventories injected Node.js SEA blobs through `inspect-artifact` /
|
||||
`inspect_artifact` and materializes their contents through `extract-artifact` /
|
||||
`extract_artifact`. It never runs the application or deserializes V8 snapshots
|
||||
or code cache.
|
||||
|
||||
```sh
|
||||
rea inspect-artifact ./application --json
|
||||
rea inspect-artifact ./application --detail summary --evidence_output evidence.json --json
|
||||
rea extract-artifact ./application --json
|
||||
```
|
||||
|
||||
For MCP, open the carrier with `open_binary`, then call `inspect_artifact`.
|
||||
Summary delivery retains the complete Evidence: use `parent_evidence_id` with
|
||||
`inspect_analysis_view` to request particular inventory paths and fields.
|
||||
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
|
||||
analyzer selects extracted files by suffix.
|
||||
|
||||
## Recognition and evidence
|
||||
|
||||
The container format is `node-sea`. REA locates Mach-O sections named
|
||||
`__NODE_SEA_BLOB` in `NODE_SEA` or `__NODE_SEA`, ELF notes owned by
|
||||
`NODE_SEA_BLOB`, and PE RCDATA resources named `NODE_SEA_BLOB`. PE resource
|
||||
locations come from the shared PE resource parser. ELF program-header
|
||||
`PT_NOTE` segments are authoritative, as at runtime; `SHT_NOTE` sections are
|
||||
read only when no program-header table is readable, because injectors can
|
||||
leave stale section entries. A PE without a `NODE_SEA_BLOB` resource name is
|
||||
left to native classification. Universal Mach-O carriers
|
||||
with different slice blobs are rejected as ambiguous; identical blobs are
|
||||
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.
|
||||
|
||||
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
|
||||
sentinel observations, not proof of runtime activation: sentinel-like text in
|
||||
script or asset bytes also participates in the scan.
|
||||
|
||||
## Supported layout and limits
|
||||
|
||||
The decoder follows the 64-bit little-endian layout in Node
|
||||
[v24.10.0 node_sea.cc](https://github.com/nodejs/node/blob/v24.10.0/src/node_sea.cc)
|
||||
and [node_sea.h](https://github.com/nodejs/node/blob/v24.10.0/src/node_sea.h),
|
||||
including the exec-argument extension byte. Older layouts without that byte,
|
||||
32-bit length fields, big-endian blobs and future flags are not supported.
|
||||
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.
|
||||
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
|
||||
names total at most 8 MiB, and 4,096 exec arguments totaling at most 1 MiB of
|
||||
text. These are fixed resource bounds, not Node format limits, checked before
|
||||
decoding names or creating records; a hit fails as a limit with no runtime
|
||||
override. Asset and argument decoding, padding scans, and inventory construction
|
||||
yield in bounded batches and honor caller cancellation. Asset count and name
|
||||
budgets bound retained metadata independently of stored member body sizes. The PE
|
||||
name probe reads headers from the first 64 KiB of the carrier; a PE whose
|
||||
headers extend beyond that is left to native classification. Snapshot and
|
||||
code-cache bytes remain opaque.
|
||||
|
||||
## Verification
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
bytes without running the injected application:
|
||||
|
||||
```sh
|
||||
npm run verify:node-sea
|
||||
```
|
||||
|
||||
This requires Docker and network access to fetch the producer image and
|
||||
postject. It creates and removes its own temporary fixture directory. A real
|
||||
macOS Mach-O or Windows PE run is separate coverage; synthetic carriers do not
|
||||
establish real-platform verification. No performance improvement is claimed;
|
||||
whole-task tokens, CPU, memory, and cold/warm timings have not been measured.
|
||||
@@ -188,6 +188,7 @@
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify/apple/keyed-archive.mjs",
|
||||
"verify:macos-bundle": "npm run build:cached && node scripts/verify/apple/macos-bundle.mjs",
|
||||
"verify:legacy-simulator": "npm run build:cached && node scripts/verify/apple/legacy-simulator.mjs",
|
||||
"verify:node-sea": "npm run build:cached && node scripts/verify-node-sea.mjs",
|
||||
"verify:architecture-guards": "node scripts/verify/architecture-guards.mjs",
|
||||
"verify:apktool": "npm run build:cached && node scripts/verify-apktool.mjs",
|
||||
"verify:pcap": "npm run build:cached && node scripts/verify-pcap.mjs",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// Requires a running Docker daemon, network access for the pinned fixture
|
||||
// producer, and `npm run build:cached`. Never executes the injected application.
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-node-sea-real-"));
|
||||
const image = "node:24.10.0-bookworm";
|
||||
const script =
|
||||
'throw new Error("SEA verification must not execute this application");\n';
|
||||
try {
|
||||
await writeFile(join(root, "main.js"), script);
|
||||
await writeFile(join(root, "asset.txt"), "real SEA asset\n");
|
||||
await writeFile(
|
||||
join(root, "sea.json"),
|
||||
JSON.stringify({
|
||||
main: "main.js",
|
||||
output: "sea.blob",
|
||||
disableExperimentalSEAWarning: true,
|
||||
useCodeCache: true,
|
||||
assets: { "data/asset.txt": "asset.txt" },
|
||||
}),
|
||||
);
|
||||
execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"run",
|
||||
"--rm",
|
||||
"--volume",
|
||||
`${root}:/fixture`,
|
||||
"--workdir",
|
||||
"/fixture",
|
||||
image,
|
||||
"sh",
|
||||
"-ec",
|
||||
"node --experimental-sea-config sea.json; cp /usr/local/bin/node application; npx --yes postject@1.0.0-alpha.6 application NODE_SEA_BLOB sea.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2",
|
||||
],
|
||||
{ stdio: "inherit", timeout: 300_000 },
|
||||
);
|
||||
const cli = (...args) =>
|
||||
JSON.parse(
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[resolve("scripts/rea.mjs"), ...args, "--json"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
env: { ...process.env, TMPDIR: root, TMP: root, TEMP: root },
|
||||
},
|
||||
),
|
||||
);
|
||||
const input = join(root, "application");
|
||||
const inspected = cli("inspect-artifact", input);
|
||||
const graph =
|
||||
inspected.normalized_result.substeps[0].evidence.normalized_result;
|
||||
assert.equal(graph.manifest.root_format, "node-sea");
|
||||
const metadata = graph.occurrences.find(
|
||||
(row) => row.logical_path === ".",
|
||||
).format_metadata;
|
||||
assert.equal(metadata.fuse_state, "set");
|
||||
assert.equal(metadata.use_code_cache, true);
|
||||
assert.equal(metadata.include_assets, true);
|
||||
const extracted = cli("extract-artifact", input).normalized_result;
|
||||
assert.equal(extracted.containment_verified, true);
|
||||
assert.equal(
|
||||
await readFile(join(extracted.output_root, "main.js"), "utf8"),
|
||||
script,
|
||||
);
|
||||
assert.equal(
|
||||
await readFile(join(extracted.output_root, "data/asset.txt"), "utf8"),
|
||||
"real SEA asset\n",
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ producer: image, metadata, verified: ["ELF inventory", "code cache", "fuse", "script extraction", "asset extraction"] }, null, 2)}\n`,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -175,6 +175,7 @@ export interface ArtifactReader {
|
||||
| "asar"
|
||||
| "godot-pck"
|
||||
| "pyinstaller"
|
||||
| "node-sea"
|
||||
| "file";
|
||||
entries(
|
||||
signal?: AbortSignal,
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import { open, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setImmediate } from "node:timers";
|
||||
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import * as artifactPaths from "./ArtifactPaths.js";
|
||||
|
||||
import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
|
||||
import {
|
||||
NODE_SEA_BAD_MAGIC,
|
||||
NODE_SEA_DUPLICATE_BLOB,
|
||||
NODE_SEA_FUSE_CONFLICT,
|
||||
NODE_SEA_FUSE_ABSENT,
|
||||
NODE_SEA_FUSE_UNSET,
|
||||
NODE_SEA_MAGIC,
|
||||
NODE_SEA_TRUNCATED,
|
||||
NODE_SEA_UNKNOWN_FLAGS,
|
||||
} from "./nodeSea/NodeSeaBlob.js";
|
||||
import {
|
||||
NODE_SEA_FUSE_HASH,
|
||||
buildNodeSeaBlob,
|
||||
nodeSeaElf,
|
||||
nodeSeaMachO,
|
||||
nodeSeaPe,
|
||||
nodeSeaUniversal,
|
||||
} from "./nodeSea/NodeSea.fixture.js";
|
||||
import { elfNoteFixture } from "../native/elf/ElfNotes.fixture.js";
|
||||
import { peResourceFixture } from "../native/pe/PeResources.fixture.js";
|
||||
import { locateNodeSea } from "./nodeSea/NodeSeaLocation.js";
|
||||
import { MAX_INSPECT_PE_FILE_BYTES } from "../domain/native/peResources.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js";
|
||||
|
||||
const writeCarrier = async (name: string, bytes: Buffer): Promise<string> => {
|
||||
const directory = await createTestTempDirectory("rea-node-sea-");
|
||||
const path = join(directory, name);
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
};
|
||||
|
||||
const script = 'console.log("rea-node-sea")\n';
|
||||
const populated = {
|
||||
codePath: "app/main.js",
|
||||
main: script,
|
||||
codeCache: Buffer.from("cache"),
|
||||
assets: [{ name: "assets/message.txt", body: "hello" }],
|
||||
execArgv: ["--no-warnings"],
|
||||
disableExperimentalSeaWarning: true,
|
||||
fuse: "set" as const,
|
||||
};
|
||||
|
||||
it.each([
|
||||
["mach-o", nodeSeaMachO(populated)],
|
||||
["elf", nodeSeaElf(populated)],
|
||||
["pe", nodeSeaPe(populated)],
|
||||
] as const)("inventories a %s Node SEA carrier", async (carrier, bytes) => {
|
||||
const path = await writeCarrier(`sea-${carrier}`, bytes);
|
||||
const snapshot = await scanCanonicalArtifactInventory(path);
|
||||
expect(snapshot.manifest.root_format).toBe("node-sea");
|
||||
expect(snapshot.coverage.status).toBe("complete");
|
||||
expect(snapshot.limitations).toContain(
|
||||
"Node SEA inventory establishes the injected blob, flags, fuse sentinel, main script, optional code cache, snapshot, and assets. V8 snapshots and code cache are not deserialized, and the application is not executed.",
|
||||
);
|
||||
expect(snapshot.limitations).not.toContain(NODE_SEA_FUSE_UNSET);
|
||||
const root = snapshot.occurrences.find((row) => row.logical_path === ".");
|
||||
expect(root).toMatchObject({ artifact_kind: "container" });
|
||||
expect(root?.format_metadata).toMatchObject({
|
||||
kind: "node-sea",
|
||||
magic: NODE_SEA_MAGIC,
|
||||
carrier,
|
||||
code_path: "app/main.js",
|
||||
disable_experimental_sea_warning: true,
|
||||
use_snapshot: false,
|
||||
use_code_cache: true,
|
||||
include_assets: true,
|
||||
include_exec_argv: true,
|
||||
exec_argv: ["--no-warnings"],
|
||||
exec_argv_extension: "env",
|
||||
fuse_state: "set",
|
||||
fuse_sentinel: `NODE_SEA_FUSE_${NODE_SEA_FUSE_HASH}:1`,
|
||||
fuse_contradiction: null,
|
||||
});
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === "app/main.js"),
|
||||
).toMatchObject({
|
||||
artifact_kind: "javascript",
|
||||
artifact_format: "javascript-bundle",
|
||||
format_metadata: { kind: "node-sea-entry", role: "main-script" },
|
||||
});
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
(row) => row.logical_path === "__node_sea__/code-cache",
|
||||
),
|
||||
).toMatchObject({
|
||||
artifact_format: "file",
|
||||
format_metadata: { role: "code-cache" },
|
||||
});
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
(row) => row.logical_path === "assets/message.txt",
|
||||
),
|
||||
).toMatchObject({ format_metadata: { role: "asset" } });
|
||||
const scriptNode = snapshot.nodes.find(
|
||||
(node) => node.size === Buffer.byteLength(script),
|
||||
);
|
||||
expect(scriptNode).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts the __NODE_SEA segment spelling and a universal Mach-O slice", async () => {
|
||||
const underscored = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"underscored",
|
||||
nodeSeaMachO({ ...populated, segmentName: "__NODE_SEA" }),
|
||||
),
|
||||
);
|
||||
expect(underscored.manifest.root_format).toBe("node-sea");
|
||||
const universal = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("universal", nodeSeaUniversal(populated)),
|
||||
);
|
||||
expect(universal.manifest.root_format).toBe("node-sea");
|
||||
expect(
|
||||
universal.occurrences.find((row) => row.logical_path === ".")
|
||||
?.format_metadata,
|
||||
).toMatchObject({ carrier: "mach-o", fuse_state: "set" });
|
||||
});
|
||||
|
||||
it("reads a 32-bit and a big-endian Mach-O section", async () => {
|
||||
for (const bytes of [
|
||||
nodeSeaMachO({ ...populated, bits: 32 }),
|
||||
nodeSeaMachO({ ...populated, littleEndian: false }),
|
||||
]) {
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("macho-variant", bytes),
|
||||
);
|
||||
expect(snapshot.manifest.root_format).toBe("node-sea");
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === "app/main.js"),
|
||||
).toMatchObject({ artifact_format: "javascript-bundle" });
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies a main script without a JavaScript suffix as javascript-bundle", async () => {
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"plain-name",
|
||||
nodeSeaElf({ codePath: "main", main: script, fuse: "set" }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === "main"),
|
||||
).toMatchObject({
|
||||
artifact_kind: "javascript",
|
||||
artifact_format: "javascript-bundle",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a snapshot body opaque and retains the code path", async () => {
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"snapshot",
|
||||
nodeSeaElf({
|
||||
codePath: "sea.js",
|
||||
snapshot: Buffer.from("snap-bytes"),
|
||||
fuse: "set",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const root = snapshot.occurrences.find((row) => row.logical_path === ".");
|
||||
expect(root?.format_metadata).toMatchObject({
|
||||
use_snapshot: true,
|
||||
code_path: "sea.js",
|
||||
});
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === "sea.js"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
snapshot.occurrences.find(
|
||||
(row) => row.logical_path === "__node_sea__/snapshot",
|
||||
),
|
||||
).toMatchObject({
|
||||
artifact_format: "file",
|
||||
format_metadata: { role: "snapshot" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent", undefined, "absent", null],
|
||||
["unset", "unset", "unset", `NODE_SEA_FUSE_${NODE_SEA_FUSE_HASH}:0`],
|
||||
] as const)(
|
||||
"reports a %s fuse as a contradiction",
|
||||
async (_label, fuse, state, sentinel) => {
|
||||
const reason =
|
||||
state === "absent" ? NODE_SEA_FUSE_ABSENT : NODE_SEA_FUSE_UNSET;
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"fuse",
|
||||
nodeSeaElf({ ...(fuse === undefined ? {} : { fuse }), main: script }),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === ".")
|
||||
?.format_metadata,
|
||||
).toMatchObject({
|
||||
fuse_state: state,
|
||||
fuse_sentinel: sentinel,
|
||||
fuse_contradiction: reason,
|
||||
});
|
||||
expect(snapshot.limitations).toContain(reason);
|
||||
expect(snapshot.coverage.status).toBe("complete");
|
||||
},
|
||||
);
|
||||
|
||||
it("reports disagreeing fuse sentinels", async () => {
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("conflict", nodeSeaPe({ fuse: "conflicting" })),
|
||||
);
|
||||
expect(
|
||||
snapshot.occurrences.find((row) => row.logical_path === ".")
|
||||
?.format_metadata,
|
||||
).toMatchObject({
|
||||
fuse_state: "conflicting",
|
||||
fuse_sentinel: null,
|
||||
fuse_contradiction: NODE_SEA_FUSE_CONFLICT,
|
||||
});
|
||||
expect(snapshot.limitations).toContain(NODE_SEA_FUSE_CONFLICT);
|
||||
});
|
||||
|
||||
it("leaves a Mach-O, ELF, or PE without a SEA slot in its native format", async () => {
|
||||
const mach = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"plain-macho",
|
||||
nodeSeaMachO({
|
||||
segmentName: "__TEXT",
|
||||
sectionName: "__text",
|
||||
blob: Buffer.from("text"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(mach.manifest.root_format).toBe("mach-o");
|
||||
const elf = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("plain-elf", elfNoteFixture({ type: 2 })),
|
||||
);
|
||||
expect(elf.manifest.root_format).toBe("elf");
|
||||
const pe = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("plain-pe", peResourceFixture(true).bytes),
|
||||
);
|
||||
expect(pe.manifest.root_format).toBe("pe");
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"scopes the large PE admission limit to SEA resources (PE32+: %s)",
|
||||
async (plus) => {
|
||||
for (const sea of [false, true]) {
|
||||
const bytes = peResourceFixture(plus, [
|
||||
{
|
||||
type: sea ? 10 : 11,
|
||||
name: "NODE_SEA_BLOB",
|
||||
language: 1033,
|
||||
payload: buildNodeSeaBlob(populated),
|
||||
},
|
||||
{
|
||||
type: 10,
|
||||
name: "OTHER_RESOURCE",
|
||||
language: 1033,
|
||||
payload: Buffer.from("other"),
|
||||
},
|
||||
]).bytes;
|
||||
const path = await writeCarrier("large-pe", bytes);
|
||||
const handle = await open(path, "r+");
|
||||
try {
|
||||
if (!sea)
|
||||
await expect(
|
||||
locateNodeSea(handle, bytes.length),
|
||||
).resolves.toBeUndefined();
|
||||
await handle.truncate(MAX_INSPECT_PE_FILE_BYTES + 1);
|
||||
const located = locateNodeSea(handle, MAX_INSPECT_PE_FILE_BYTES + 1);
|
||||
if (sea)
|
||||
await expect(located).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message:
|
||||
"Node SEA PE inspection exceeds the 512 MiB PE parser input ceiling",
|
||||
});
|
||||
else await expect(located).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("trusts the PT_NOTE blob over a stale SHT_NOTE entry", async () => {
|
||||
const bytes = nodeSeaElf({ ...populated, ownership: "both" });
|
||||
// ELF64: the note section header follows one 56-byte program header.
|
||||
const noteOffsetField = 64 + 56 + 64 + 24;
|
||||
const noteOffset = bytes.readBigUInt64LE(noteOffsetField);
|
||||
bytes.writeBigUInt64LE(noteOffset + 20n, noteOffsetField);
|
||||
const inventory = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier("stale-section-note", bytes),
|
||||
);
|
||||
expect(inventory.manifest.root_format).toBe("node-sea");
|
||||
});
|
||||
|
||||
it("leaves a non-SEA PE with a cyclic resource tree to native classification", async () => {
|
||||
const fixture = peResourceFixture(true);
|
||||
fixture.bytes.writeUInt32LE(0x80000000, (fixture.entryOffsets[0] ?? 0) + 4);
|
||||
const path = await writeCarrier("cyclic-pe", fixture.bytes);
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
await expect(
|
||||
locateNodeSea(handle, fixture.bytes.length),
|
||||
).resolves.toBeUndefined();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const inventory = await scanCanonicalArtifactInventory(path);
|
||||
expect(inventory.manifest.root_format).not.toBe("node-sea");
|
||||
});
|
||||
|
||||
it("fails closed when the located blob is truncated, has a bad magic, or unknown flags", async () => {
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"truncated",
|
||||
nodeSeaElf({ blob: Buffer.from([0x20, 0xda, 0x43]) }),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "format", message: NODE_SEA_TRUNCATED });
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier("bad-magic", nodeSeaMachO({ blob: Buffer.alloc(16) })),
|
||||
),
|
||||
).rejects.toMatchObject({ reason: "format", message: NODE_SEA_BAD_MAGIC });
|
||||
const unknown = Buffer.alloc(9);
|
||||
unknown.writeUInt32LE(NODE_SEA_MAGIC, 0);
|
||||
unknown.writeUInt32LE(1 << 5, 4);
|
||||
unknown[8] = 1;
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier("unknown-flags", nodeSeaPe({ blob: unknown })),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: NODE_SEA_UNKNOWN_FLAGS,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when two different blobs are declared", async () => {
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"dup-elf",
|
||||
nodeSeaElf({
|
||||
extraBlobs: [buildNodeSeaBlob({ main: "other\n" })],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: NODE_SEA_DUPLICATE_BLOB,
|
||||
});
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"dup-macho",
|
||||
nodeSeaMachO({
|
||||
extraBlobs: [buildNodeSeaBlob({ codePath: "other.js", main: "x" })],
|
||||
}),
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ message: NODE_SEA_DUPLICATE_BLOB });
|
||||
});
|
||||
|
||||
it("keeps one blob when the same bytes are declared twice", async () => {
|
||||
const blob = buildNodeSeaBlob({ main: script, codePath: "sea.js" });
|
||||
const snapshot = await scanCanonicalArtifactInventory(
|
||||
await writeCarrier(
|
||||
"same",
|
||||
nodeSeaElf({ blob, extraBlobs: [blob], fuse: "set" }),
|
||||
),
|
||||
);
|
||||
expect(snapshot.manifest.root_format).toBe("node-sea");
|
||||
expect(
|
||||
snapshot.occurrences.filter((row) => row.logical_path === "sea.js"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("extracts the main script and assets without executing them", async () => {
|
||||
const directory = await createTestTempDirectory("rea-node-sea-extract-");
|
||||
const path = join(directory, "sea.elf");
|
||||
await writeFile(path, nodeSeaElf(populated));
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
try {
|
||||
const extracted = await extractArtifact({
|
||||
inputPath: path,
|
||||
inputFormat: "elf",
|
||||
outputRoot: join(directory, "out"),
|
||||
environment: process.env,
|
||||
integrityPolicy: "fail",
|
||||
resourceScope,
|
||||
});
|
||||
expect(
|
||||
await readFile(join(extracted.output_root, "app/main.js"), "utf8"),
|
||||
).toBe(script);
|
||||
expect(
|
||||
await readFile(join(extracted.output_root, "assets/message.txt"), "utf8"),
|
||||
).toBe("hello");
|
||||
expect(
|
||||
await readFile(join(extracted.output_root, "__node_sea__/code-cache")),
|
||||
).toEqual(Buffer.from("cache"));
|
||||
const extractedInventory = await scanCanonicalArtifactInventory(
|
||||
extracted.output_root,
|
||||
);
|
||||
expect(extractedInventory.manifest.root_format).toBe("directory");
|
||||
expect(
|
||||
extractedInventory.occurrences.find(
|
||||
(row) => row.logical_path === "app/main.js",
|
||||
)?.artifact_format,
|
||||
).toBe("javascript-bundle");
|
||||
} finally {
|
||||
await resourceScope.close();
|
||||
}
|
||||
});
|
||||
|
||||
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("retains source asset spelling separately from normalized graph paths", async () => {
|
||||
const path = await writeCarrier(
|
||||
"source-path",
|
||||
nodeSeaElf({ assets: [{ name: "data/cafe\u0301.txt", body: "asset" }] }),
|
||||
);
|
||||
const graph = await scanCanonicalArtifactInventory(path);
|
||||
expect(
|
||||
graph.occurrences.find((row) => row.logical_path === "data/café.txt")
|
||||
?.format_metadata,
|
||||
).toMatchObject({ role: "asset", source_path: "data/cafe\u0301.txt" });
|
||||
});
|
||||
|
||||
it("rejects missing Mach-O load commands after a confirmed SEA section", async () => {
|
||||
const bytes = nodeSeaMachO();
|
||||
bytes.writeUInt32LE(2, 16);
|
||||
await expect(
|
||||
scanCanonicalArtifactInventory(await writeCarrier("commands", bytes)),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "Node SEA Mach-O load commands are truncated",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects forged entries and changed carrier identity, then closes idempotently", async () => {
|
||||
const path = await writeCarrier("identity", nodeSeaElf());
|
||||
const reader = new NodeSeaArtifactReader(path);
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries[0];
|
||||
if (entry === undefined) throw new Error("Missing main script");
|
||||
await expect(
|
||||
reader.open({ ...entry, byteOffset: 0 }),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
await writeFile(path, nodeSeaElf({ main: "changed bytes" }));
|
||||
await expect(reader.open(entry)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("settles an active member stream when closed", async () => {
|
||||
const path = await writeCarrier(
|
||||
"close",
|
||||
nodeSeaElf({ main: Buffer.alloc(1024 * 1024, 65) }),
|
||||
);
|
||||
const reader = new NodeSeaArtifactReader(path);
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
const entry = entries[0];
|
||||
if (entry === undefined) throw new Error("Missing main script");
|
||||
const stream = await reader.open(entry);
|
||||
await reader.close();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("honors cancellation before admitting a carrier", async () => {
|
||||
const path = await writeCarrier("cancel", nodeSeaElf());
|
||||
const reader = new NodeSeaArtifactReader(path);
|
||||
const controller = new AbortController();
|
||||
controller.abort("test cancellation");
|
||||
try {
|
||||
await expect(
|
||||
reader.entries(controller.signal)[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject({ reason: "cancelled" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["cancel", "close"] as const)(
|
||||
"settles preparation when the caller requests %s during inventory construction",
|
||||
async (action) => {
|
||||
const path = await writeCarrier(
|
||||
"large-table",
|
||||
nodeSeaElf({
|
||||
assets: [
|
||||
...Array.from({ length: 1024 }, (_, index) => ({
|
||||
name: `asset-${String(index)}`,
|
||||
body: "",
|
||||
})),
|
||||
{ name: "../unsafe", body: "" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const reader = new NodeSeaArtifactReader(path);
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "building inventory" };
|
||||
let completion: Promise<void> | undefined;
|
||||
const normalize = artifactPaths.normalizeArtifactPath;
|
||||
// Schedule the request from a real consumer boundary after construction
|
||||
// has begun. A synchronous whole-table pass reaches the unsafe tail first.
|
||||
const observer = vi
|
||||
.spyOn(artifactPaths, "normalizeArtifactPath")
|
||||
.mockImplementation((name) => {
|
||||
if (name === "asset-10")
|
||||
setImmediate(() => {
|
||||
if (action === "cancel") controller.abort(reason);
|
||||
else completion = reader.close();
|
||||
});
|
||||
return normalize(name);
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
reader.entries(controller.signal)[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject(
|
||||
action === "cancel"
|
||||
? { reason: "cancelled", cause: reason }
|
||||
: { reason: "unavailable" },
|
||||
);
|
||||
await completion;
|
||||
await reader.close();
|
||||
await expect(
|
||||
reader.entries()[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject({ reason: "unavailable" });
|
||||
} finally {
|
||||
observer.mockRestore();
|
||||
await reader.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,446 @@
|
||||
import type { Stats } from "node:fs";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { Readable, Transform, pipeline } from "node:stream";
|
||||
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 {
|
||||
ArtifactReaderFailure,
|
||||
copyArtifactEntry,
|
||||
sameArtifactEntry,
|
||||
type ArtifactEntry,
|
||||
type ArtifactReader,
|
||||
type ArtifactTraversalOptions,
|
||||
} from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import { hashReadable } from "./ArtifactHash.js";
|
||||
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";
|
||||
import {
|
||||
locateNodeSea,
|
||||
readNodeSeaBlob,
|
||||
scanNodeSeaFuse,
|
||||
} from "./nodeSea/NodeSeaLocation.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import {
|
||||
NonRegularFileReadError,
|
||||
RegularFileAdmissionFailure,
|
||||
RegularFileOperationCancelled,
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
|
||||
const SNAPSHOT_PATH = "__node_sea__/snapshot";
|
||||
const CODE_CACHE_PATH = "__node_sea__/code-cache";
|
||||
|
||||
type NodeSeaRole = "main-script" | "snapshot" | "code-cache" | "asset";
|
||||
|
||||
/** Read a Node.js SEA blob out of a Mach-O, ELF, or PE carrier without running it. */
|
||||
export class NodeSeaArtifactReader implements ArtifactReader {
|
||||
readonly format = "node-sea" as const;
|
||||
readonly #scope = new ArtifactResourceScope();
|
||||
readonly #entries = new Map<string, ArtifactEntry>();
|
||||
readonly #streams = new Set<Readable>();
|
||||
#owner: OwnedFileHandle | undefined;
|
||||
#initial: Stats | undefined;
|
||||
#opening: Promise<FileHandle> | undefined;
|
||||
#prepared: Promise<void> | undefined;
|
||||
#header: NodeSeaMetadata | undefined;
|
||||
#closing = false;
|
||||
#boundSha256: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
private readonly admitted?: StableRegularFileDescriptor,
|
||||
) {}
|
||||
|
||||
formatMetadata(): NodeSeaMetadata | undefined {
|
||||
if (this.#header === undefined) return undefined;
|
||||
return { ...this.#header, exec_argv: [...this.#header.exec_argv] };
|
||||
}
|
||||
|
||||
async prepareContainer(
|
||||
expectedSha256: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
await this.#scope.run(async () => {
|
||||
this.#assertOpen(signal);
|
||||
const handle = await this.#assertStable(signal);
|
||||
const observed =
|
||||
this.#boundSha256 ??
|
||||
(
|
||||
await hashReadable(
|
||||
readFileHandleChunks(handle, {
|
||||
signal,
|
||||
cancellationFailure: (reason) =>
|
||||
new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Node SEA verification cancelled",
|
||||
{ cause: reason },
|
||||
),
|
||||
}),
|
||||
signal,
|
||||
)
|
||||
).sha256;
|
||||
await this.#assertStable(signal);
|
||||
if (observed !== expectedSha256)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Node SEA carrier changed after inventory",
|
||||
undefined,
|
||||
artifactIntegrityDetails(
|
||||
".",
|
||||
{ algorithm: "sha256", digest: expectedSha256 },
|
||||
observed,
|
||||
false,
|
||||
),
|
||||
);
|
||||
this.#boundSha256 = observed;
|
||||
});
|
||||
}
|
||||
|
||||
async *entries(
|
||||
signal?: AbortSignal,
|
||||
options?: ArtifactTraversalOptions,
|
||||
): AsyncIterable<ArtifactEntry> {
|
||||
await this.#scope.run(async () => {
|
||||
this.#assertOpen(signal);
|
||||
this.#prepared ??= this.#prepare(signal);
|
||||
await this.#prepared;
|
||||
await this.#assertStable(signal);
|
||||
});
|
||||
const contradiction = this.#header?.fuse_contradiction;
|
||||
if (contradiction !== null && contradiction !== undefined)
|
||||
options?.onCoverage?.({
|
||||
status: "complete",
|
||||
limitations: [contradiction],
|
||||
});
|
||||
let index = 0;
|
||||
for (const entry of this.#entries.values()) {
|
||||
if (index % 256 === 0) await setImmediate();
|
||||
index += 1;
|
||||
this.#assertOpen(signal);
|
||||
yield copyArtifactEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
||||
return this.#scope.run(async () => {
|
||||
this.#assertOpen(signal);
|
||||
const stored = this.#entries.get(entry.adapterKey);
|
||||
if (stored === undefined || !sameArtifactEntry(entry, stored))
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Node SEA entry does not match its owning inventory",
|
||||
);
|
||||
const handle = await this.#assertStable(signal);
|
||||
this.#assertOpen(signal);
|
||||
if (stored.byteOffset === null || stored.declaredSize === null)
|
||||
throw formatFailure("member has no physical byte range");
|
||||
const source =
|
||||
stored.declaredSize === 0
|
||||
? Readable.from([])
|
||||
: readFileHandleChunks(handle, {
|
||||
start: stored.byteOffset,
|
||||
end: stored.byteOffset + stored.declaredSize - 1,
|
||||
signal,
|
||||
cancellationFailure: (reason) =>
|
||||
new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Node SEA read cancelled",
|
||||
{
|
||||
cause: reason,
|
||||
},
|
||||
),
|
||||
});
|
||||
const output = this.#memberStream(stored, signal);
|
||||
output.on("error", () => undefined);
|
||||
this.#streams.add(output);
|
||||
void this.#scope
|
||||
.run(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
pipeline(source, output, () => {
|
||||
this.#streams.delete(output);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
)
|
||||
.catch((cause: unknown) =>
|
||||
output.destroy(
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error("Node SEA stream ownership failed", { cause }),
|
||||
),
|
||||
);
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
for (const stream of this.#streams)
|
||||
stream.destroy(
|
||||
new ArtifactReaderFailure("unavailable", "Node SEA reader closed"),
|
||||
);
|
||||
await this.#scope.close();
|
||||
if (this.#owner !== undefined) {
|
||||
await this.#owner.close();
|
||||
this.#owner = undefined;
|
||||
}
|
||||
this.#entries.clear();
|
||||
}
|
||||
|
||||
provenance(): readonly [] {
|
||||
return [];
|
||||
}
|
||||
|
||||
#assertOpen(signal?: AbortSignal): void {
|
||||
assertArtifactReadActive(signal);
|
||||
if (this.#closing)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Node SEA reader is closed",
|
||||
);
|
||||
}
|
||||
|
||||
#open(signal?: AbortSignal): Promise<FileHandle> {
|
||||
this.#opening ??= this.#admit(signal);
|
||||
return this.#opening;
|
||||
}
|
||||
|
||||
async #admit(signal?: AbortSignal): Promise<FileHandle> {
|
||||
try {
|
||||
const handle =
|
||||
this.admitted?.handle ??
|
||||
(await openRegularFile(this.path, { symlinks: "reject", signal }));
|
||||
if (this.admitted === undefined)
|
||||
this.#owner = new OwnedFileHandle(handle);
|
||||
this.#initial = this.admitted?.initial ?? (await handle.stat());
|
||||
return handle;
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof RegularFileAdmissionFailure) {
|
||||
this.#owner = cause.owner;
|
||||
throw ArtifactReaderFailure.withCleanup(
|
||||
admissionFailure(cause.cause, this.path),
|
||||
ArtifactReaderFailure.cleanupObservation(
|
||||
cause.cleanupCause,
|
||||
this.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw admissionFailure(cause, this.path);
|
||||
}
|
||||
}
|
||||
|
||||
async #assertStable(signal?: AbortSignal): Promise<FileHandle> {
|
||||
assertArtifactReadActive(signal);
|
||||
const handle = await this.#open(signal);
|
||||
const observed = await handle.stat();
|
||||
if (
|
||||
this.#initial === undefined ||
|
||||
!sameRegularFileState(this.#initial, observed)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Node SEA source identity changed: ${this.path}`,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
return handle;
|
||||
}
|
||||
|
||||
async #prepare(signal?: AbortSignal): Promise<void> {
|
||||
const handle = await this.#assertStable(signal);
|
||||
const initial = this.#initial;
|
||||
if (initial === undefined)
|
||||
throw formatFailure("source identity was not admitted");
|
||||
const location = await locateNodeSea(handle, initial.size, signal);
|
||||
if (location === undefined) throw formatFailure("blob was not found");
|
||||
if (location.blobLength > NODE_SEA_MAX_BLOB_BYTES)
|
||||
throw new ArtifactReaderFailure("limit", NODE_SEA_BLOB_TOO_LARGE, {
|
||||
boundary: "memory",
|
||||
});
|
||||
const decoded = await decodeNodeSeaBlob(
|
||||
await readNodeSeaBlob(handle, location, initial.size, signal),
|
||||
signal,
|
||||
);
|
||||
const fuse = await scanNodeSeaFuse(handle, initial.size, signal);
|
||||
let declaredBytes = 0n;
|
||||
const expansionBudget = BigInt(
|
||||
artifactExpansionBudgetBytes(location.blobLength),
|
||||
);
|
||||
let index = 0;
|
||||
for (const member of seaMembers(decoded)) {
|
||||
if (index % 256 === 0) await setImmediate();
|
||||
this.#assertOpen(signal);
|
||||
declaredBytes += BigInt(member.range.length);
|
||||
if (declaredBytes > expansionBudget)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
`Node SEA readable entries declare ${String(declaredBytes)} bytes, exceeding this blob's ${String(expansionBudget)}-byte expansion budget`,
|
||||
{ boundary: "memory" },
|
||||
);
|
||||
const start = location.blobOffset + member.range.offset;
|
||||
if (start > initial.size || member.range.length > initial.size - start)
|
||||
throw formatFailure(`member exceeds the carrier: ${member.path}`);
|
||||
const entry = {
|
||||
path: member.path,
|
||||
kind: "file" as const,
|
||||
declaredSize: member.range.length,
|
||||
compressedSize: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
byteOffset: start,
|
||||
declaredIntegrity: null,
|
||||
unpacked: false,
|
||||
limitations: [],
|
||||
adapterKey: `node-sea:${String(index)}`,
|
||||
formatMetadata: {
|
||||
kind: "node-sea-entry" as const,
|
||||
role: member.role,
|
||||
source_path: member.path,
|
||||
index,
|
||||
},
|
||||
} satisfies ArtifactEntry;
|
||||
this.#entries.set(entry.adapterKey, entry);
|
||||
index += 1;
|
||||
}
|
||||
this.#header = {
|
||||
kind: "node-sea",
|
||||
magic: decoded.magic,
|
||||
flags: decoded.flags,
|
||||
disable_experimental_sea_warning: decoded.disableExperimentalSeaWarning,
|
||||
use_snapshot: decoded.useSnapshot,
|
||||
use_code_cache: decoded.useCodeCache,
|
||||
include_assets: decoded.includeAssets,
|
||||
include_exec_argv: decoded.includeExecArgv,
|
||||
exec_argv_extension: decoded.execArgvExtension,
|
||||
exec_argv: [...decoded.execArgv],
|
||||
code_path: decoded.codePath,
|
||||
carrier: location.carrier,
|
||||
blob_offset: location.blobOffset,
|
||||
blob_length: location.blobLength,
|
||||
fuse_state: fuse.fuseState,
|
||||
fuse_sentinel: fuse.fuseSentinel,
|
||||
fuse_contradiction: fuse.fuseContradiction,
|
||||
};
|
||||
await this.#assertStable(signal);
|
||||
}
|
||||
|
||||
#memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform {
|
||||
let bytes = 0;
|
||||
const check = async (): Promise<void> => {
|
||||
if (bytes !== entry.declaredSize)
|
||||
throw formatFailure(`member ended early: ${entry.path}`);
|
||||
await this.#assertStable(signal);
|
||||
};
|
||||
return new Transform({
|
||||
transform(chunk: Buffer, _encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
callback(null, chunk);
|
||||
},
|
||||
flush(callback) {
|
||||
void check().then(
|
||||
() => callback(),
|
||||
(cause: unknown) =>
|
||||
callback(
|
||||
cause instanceof Error
|
||||
? cause
|
||||
: new Error("Node SEA read failed", { cause }),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function* seaMembers(decoded: DecodedNodeSeaBlob): Generator<{
|
||||
readonly path: 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)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
role === "asset" && existing === "asset"
|
||||
? nodeSeaAssetCollision(normalized)
|
||||
: role === "asset" || existing === "asset"
|
||||
? NODE_SEA_RESERVED_COLLISION
|
||||
: `Node SEA entry path collides: ${normalized}`,
|
||||
);
|
||||
claimed.set(normalized, role);
|
||||
return path;
|
||||
};
|
||||
if (decoded.mainScript !== null)
|
||||
yield {
|
||||
path: claim(decoded.codePath, "main-script"),
|
||||
role: "main-script",
|
||||
range: decoded.mainScript,
|
||||
};
|
||||
if (decoded.snapshot !== null)
|
||||
yield {
|
||||
path: claim(SNAPSHOT_PATH, "snapshot"),
|
||||
role: "snapshot",
|
||||
range: decoded.snapshot,
|
||||
};
|
||||
if (decoded.codeCache !== null)
|
||||
yield {
|
||||
path: claim(CODE_CACHE_PATH, "code-cache"),
|
||||
role: "code-cache",
|
||||
range: decoded.codeCache,
|
||||
};
|
||||
for (const asset of decoded.assets)
|
||||
yield {
|
||||
path: claim(asset.name, "asset"),
|
||||
role: "asset",
|
||||
range: asset.range,
|
||||
};
|
||||
}
|
||||
|
||||
const formatFailure = (detail: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("format", `Node SEA blob is malformed: ${detail}`);
|
||||
|
||||
const admissionFailure = (
|
||||
cause: unknown,
|
||||
path: string,
|
||||
): ArtifactReaderFailure => {
|
||||
if (cause instanceof ArtifactReaderFailure) return cause;
|
||||
if (cause instanceof RegularFileOperationCancelled)
|
||||
return new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Node SEA admission cancelled",
|
||||
{
|
||||
cause: cause.reason,
|
||||
},
|
||||
);
|
||||
if (cause instanceof NonRegularFileReadError)
|
||||
return new ArtifactReaderFailure(
|
||||
"format",
|
||||
`Node SEA source is not a regular file: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
return new ArtifactReaderFailure(
|
||||
"io",
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: `Cannot admit Node SEA source: ${path}`,
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
@@ -30,8 +30,10 @@ import { SafeOutputTreeCreationFailure } from "../SafeOutputTreeCreationFailure.
|
||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import { isZipPackageFormat } from "../../domain/zipPackageFormat.js";
|
||||
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import { NodeSeaArtifactReader } from "../NodeSeaArtifactReader.js";
|
||||
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
|
||||
import { containsPyInstallerArchive } from "../pyinstaller/CArchiveLayout.js";
|
||||
import { locateNodeSea } from "../nodeSea/NodeSeaLocation.js";
|
||||
import {
|
||||
artifactExtractionResultSchema,
|
||||
type ArtifactExtractionResult,
|
||||
@@ -499,6 +501,8 @@ const requireExtractableFormat = async (
|
||||
if (format !== "elf" && format !== "pe" && format !== "mach-o")
|
||||
return false;
|
||||
abortIfNeeded(signal);
|
||||
if (await locateNodeSea(handle, size, signal)) return true;
|
||||
abortIfNeeded(signal);
|
||||
const packaged = await containsPyInstallerArchive(handle, size, signal);
|
||||
abortIfNeeded(signal);
|
||||
return packaged;
|
||||
@@ -527,6 +531,7 @@ const createReader = async (
|
||||
if (containerFormat === "godot-pck") return new GodotPckArtifactReader(path);
|
||||
if (containerFormat === "pyinstaller")
|
||||
return new PyInstallerArtifactReader(path);
|
||||
if (containerFormat === "node-sea") return new NodeSeaArtifactReader(path);
|
||||
if (await requireExtractableFormat(path, input, signal))
|
||||
return new DirectoryArtifactReader(path);
|
||||
if (format === "asar")
|
||||
|
||||
@@ -268,7 +268,9 @@ export const rootOccurrenceFor = (
|
||||
artifact_kind:
|
||||
metadata.format === "directory" ||
|
||||
isZipPackageFormat(metadata.format) ||
|
||||
["asar", "godot-pck", "dmg", "pkg", "pyinstaller"].includes(metadata.format)
|
||||
["asar", "godot-pck", "dmg", "pkg", "pyinstaller", "node-sea"].includes(
|
||||
metadata.format,
|
||||
)
|
||||
? "container"
|
||||
: artifactRoleForFormat(metadata.path, metadata.format).kind,
|
||||
artifact_format: metadata.format,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.j
|
||||
import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
|
||||
|
||||
import { containsPyInstallerArchive } from "../pyinstaller/CArchiveLayout.js";
|
||||
import { decodeNodeSeaBlob } from "../nodeSea/NodeSeaBlob.js";
|
||||
import { locateNodeSea, readNodeSeaBlob } from "../nodeSea/NodeSeaLocation.js";
|
||||
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { claimsExecutableHeader } from "../../domain/binaryTarget.js";
|
||||
@@ -130,7 +132,8 @@ export const classifyAndHashRootForInventory = async (
|
||||
isZipPackageFormat(format) ||
|
||||
format === "mach-o-universal" ||
|
||||
format === "godot-pck" ||
|
||||
format === "pyinstaller";
|
||||
format === "pyinstaller" ||
|
||||
format === "node-sea";
|
||||
outcome = {
|
||||
kind: "completed",
|
||||
value: {
|
||||
@@ -221,6 +224,15 @@ const classifyRootFormat = async (
|
||||
)
|
||||
return zipRootPackageFormat(lower);
|
||||
if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) {
|
||||
const sea = await locateNodeSea(handle, archiveBytes, signal);
|
||||
abortIfNeeded(signal);
|
||||
if (sea !== undefined) {
|
||||
await decodeNodeSeaBlob(
|
||||
await readNodeSeaBlob(handle, sea, archiveBytes, signal),
|
||||
signal,
|
||||
);
|
||||
return "node-sea";
|
||||
}
|
||||
const packaged = await containsPyInstallerArchive(
|
||||
handle,
|
||||
archiveBytes,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
||||
import { GodotPckArtifactReader } from "../GodotPckArtifactReader.js";
|
||||
import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js";
|
||||
import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js";
|
||||
import { NodeSeaArtifactReader } from "../NodeSeaArtifactReader.js";
|
||||
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
|
||||
import { ZipArtifactReader } from "../ZipArtifactReader.js";
|
||||
import type {
|
||||
@@ -41,6 +42,8 @@ export const createReader = (
|
||||
);
|
||||
case "pyinstaller":
|
||||
return new PyInstallerArtifactReader(path, rootSource);
|
||||
case "node-sea":
|
||||
return new NodeSeaArtifactReader(path, rootSource);
|
||||
case "godot-pck":
|
||||
return new GodotPckArtifactReader(path, rootSource);
|
||||
case "mach-o-universal":
|
||||
@@ -76,6 +79,11 @@ export const inventoryCoverage = (
|
||||
"PCK inventory establishes packaged bytes and metadata; scenes, binary resources, GDScript bytecode and runtime signal behavior were not decoded.",
|
||||
]
|
||||
: []),
|
||||
...(reader.format === "node-sea"
|
||||
? [
|
||||
"Node SEA inventory establishes the injected blob, flags, fuse sentinel, main script, optional code cache, snapshot, and assets. V8 snapshots and code cache are not deserialized, and the application is not executed.",
|
||||
]
|
||||
: []),
|
||||
...traversal.limitations,
|
||||
],
|
||||
};
|
||||
@@ -98,7 +106,8 @@ export const inventoryCoverage = (
|
||||
isZipPackageFormat(format) ||
|
||||
format === "directory" ||
|
||||
format === "asar" ||
|
||||
format === "godot-pck"
|
||||
format === "godot-pck" ||
|
||||
format === "node-sea"
|
||||
)
|
||||
return {
|
||||
coverage: { scope: "container-entries", status: "unknown" },
|
||||
|
||||
@@ -231,8 +231,15 @@ const visitArtifactEntries = async (
|
||||
else {
|
||||
context.nodes.set(digested.node.artifact_id, digested.node);
|
||||
occurrence.artifact_id = digested.node.artifact_id;
|
||||
occurrence.artifact_kind = digested.classification.kind;
|
||||
occurrence.artifact_format = digested.classification.format;
|
||||
const seaMainScript =
|
||||
entry.formatMetadata?.kind === "node-sea-entry" &&
|
||||
entry.formatMetadata.role === "main-script";
|
||||
occurrence.artifact_kind = seaMainScript
|
||||
? "javascript"
|
||||
: digested.classification.kind;
|
||||
occurrence.artifact_format = seaMainScript
|
||||
? "javascript-bundle"
|
||||
: digested.classification.format;
|
||||
occurrence.hash_status = digested.mismatched
|
||||
? "mismatched"
|
||||
: "verified";
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { elfNote, elfNoteFixture } from "../../native/elf/ElfNotes.fixture.js";
|
||||
import { peResourceFixture } from "../../native/pe/PeResources.fixture.js";
|
||||
|
||||
import { NODE_SEA_MAGIC } from "./NodeSeaBlob.js";
|
||||
|
||||
/** Official Node `NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2` hash. */
|
||||
export const NODE_SEA_FUSE_HASH = "fce680ab2cc467b6e072b8b5df1996b2";
|
||||
|
||||
export type NodeSeaFuseFixture = "set" | "unset" | "absent" | "conflicting";
|
||||
|
||||
export interface NodeSeaAssetFixture {
|
||||
readonly name: string;
|
||||
readonly body: Buffer | string;
|
||||
}
|
||||
|
||||
export interface NodeSeaBlobFixtureOptions {
|
||||
readonly codePath?: string;
|
||||
readonly main?: Buffer | string;
|
||||
readonly snapshot?: Buffer | string;
|
||||
readonly codeCache?: Buffer | string;
|
||||
readonly assets?: readonly NodeSeaAssetFixture[];
|
||||
readonly execArgv?: readonly string[];
|
||||
readonly execArgvExtension?: 0 | 1 | 2;
|
||||
readonly disableExperimentalSeaWarning?: boolean;
|
||||
readonly trailing?: Buffer;
|
||||
}
|
||||
|
||||
export interface NodeSeaCarrierOptions extends NodeSeaBlobFixtureOptions {
|
||||
/** Raw blob bytes. When set, the structured fields above are ignored. */
|
||||
readonly blob?: Buffer;
|
||||
readonly fuse?: NodeSeaFuseFixture;
|
||||
readonly extraBlobs?: readonly Buffer[];
|
||||
}
|
||||
|
||||
const asBytes = (value: Buffer | string): Buffer =>
|
||||
typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
||||
|
||||
const u64 = (value: number): Buffer => {
|
||||
const bytes = Buffer.alloc(8);
|
||||
bytes.writeBigUInt64LE(BigInt(value));
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const sized = (value: Buffer | string): Buffer => {
|
||||
const body = asBytes(value);
|
||||
return Buffer.concat([u64(body.length), body]);
|
||||
};
|
||||
|
||||
/** Build a Node 24 SEA blob: magic, flags, extension, then length-prefixed fields. */
|
||||
export const buildNodeSeaBlob = (
|
||||
options: NodeSeaBlobFixtureOptions = {},
|
||||
): Buffer => {
|
||||
const codePath = options.codePath ?? "sea.js";
|
||||
const snapshot = options.snapshot;
|
||||
const body = snapshot ?? options.main ?? 'console.log("rea-node-sea")\n';
|
||||
const assets = options.assets ?? [];
|
||||
const execArgv = options.execArgv ?? [];
|
||||
let flags = 0;
|
||||
if (options.disableExperimentalSeaWarning === true) flags |= 1 << 0;
|
||||
if (snapshot !== undefined) flags |= 1 << 1;
|
||||
if (options.codeCache !== undefined) flags |= 1 << 2;
|
||||
if (assets.length > 0) flags |= 1 << 3;
|
||||
if (execArgv.length > 0) flags |= 1 << 4;
|
||||
const header = Buffer.alloc(9);
|
||||
header.writeUInt32LE(NODE_SEA_MAGIC, 0);
|
||||
header.writeUInt32LE(flags, 4);
|
||||
header[8] = options.execArgvExtension ?? 1;
|
||||
const parts: Buffer[] = [header, sized(codePath), sized(body)];
|
||||
if (options.codeCache !== undefined) parts.push(sized(options.codeCache));
|
||||
if (assets.length > 0) {
|
||||
parts.push(u64(assets.length));
|
||||
for (const asset of assets)
|
||||
parts.push(sized(asset.name), sized(asset.body));
|
||||
}
|
||||
if (execArgv.length > 0) {
|
||||
parts.push(u64(execArgv.length));
|
||||
for (const argument of execArgv) parts.push(sized(argument));
|
||||
}
|
||||
if (options.trailing !== undefined) parts.push(options.trailing);
|
||||
return Buffer.concat(parts);
|
||||
};
|
||||
|
||||
export const nodeSeaFuseBytes = (
|
||||
state: NodeSeaFuseFixture = "absent",
|
||||
): Buffer => {
|
||||
if (state === "absent") return Buffer.alloc(0);
|
||||
const sentinel = (digit: string, hash = NODE_SEA_FUSE_HASH): Buffer =>
|
||||
Buffer.from(`\0NODE_SEA_FUSE_${hash}:${digit}\0`, "latin1");
|
||||
if (state === "set") return sentinel("1");
|
||||
if (state === "unset") return sentinel("0");
|
||||
return Buffer.concat([
|
||||
sentinel("0"),
|
||||
sentinel("1", "0123456789abcdef0123456789abcdef"),
|
||||
]);
|
||||
};
|
||||
|
||||
export interface NodeSeaMachOOptions extends NodeSeaCarrierOptions {
|
||||
readonly segmentName?: string;
|
||||
readonly sectionName?: string;
|
||||
readonly bits?: 32 | 64;
|
||||
readonly littleEndian?: boolean;
|
||||
}
|
||||
|
||||
/** Thin Mach-O whose SEA section file offset is relative to this image. */
|
||||
export const nodeSeaMachO = (options: NodeSeaMachOOptions = {}): Buffer => {
|
||||
const blobs = carrierBlobs(options);
|
||||
const little = options.littleEndian ?? true;
|
||||
const wide = (options.bits ?? 64) === 64;
|
||||
const headerSize = wide ? 32 : 28;
|
||||
const segmentHeader = wide ? 72 : 56;
|
||||
const sectionSize = wide ? 80 : 68;
|
||||
const segmentName = options.segmentName ?? "NODE_SEA";
|
||||
const sectionName = options.sectionName ?? "__NODE_SEA_BLOB";
|
||||
const cmdsize = segmentHeader + blobs.length * sectionSize;
|
||||
const payloadAt = headerSize + cmdsize;
|
||||
const payloadOffsets: number[] = [];
|
||||
let cursor = payloadAt;
|
||||
for (const blob of blobs) {
|
||||
payloadOffsets.push(cursor);
|
||||
cursor += blob.length;
|
||||
}
|
||||
const image = Buffer.alloc(cursor);
|
||||
write32(image, 0, wide ? 0xfeedfacf : 0xfeedface, little);
|
||||
write32(image, 4, wide ? 0x0100000c : 7, little);
|
||||
write32(image, 12, 2, little);
|
||||
write32(image, 16, 1, little);
|
||||
write32(image, 20, cmdsize, little);
|
||||
const command = headerSize;
|
||||
write32(image, command, wide ? 0x19 : 0x1, little);
|
||||
write32(image, command + 4, cmdsize, little);
|
||||
writeName(image, command + 8, segmentName);
|
||||
const payloadSize = cursor - payloadAt;
|
||||
if (wide) {
|
||||
write64(image, command + 32, payloadSize, little);
|
||||
write64(image, command + 40, payloadAt, little);
|
||||
write64(image, command + 48, payloadSize, little);
|
||||
write32(image, command + 56, 7, little);
|
||||
write32(image, command + 60, 5, little);
|
||||
write32(image, command + 64, blobs.length, little);
|
||||
} else {
|
||||
write32(image, command + 28, payloadSize, little);
|
||||
write32(image, command + 32, payloadAt, little);
|
||||
write32(image, command + 36, payloadSize, little);
|
||||
write32(image, command + 40, 7, little);
|
||||
write32(image, command + 44, 5, little);
|
||||
write32(image, command + 48, blobs.length, little);
|
||||
}
|
||||
blobs.forEach((blob, index) => {
|
||||
const section = command + segmentHeader + index * sectionSize;
|
||||
const fileOffset = payloadOffsets[index] ?? payloadAt;
|
||||
writeName(image, section, sectionName);
|
||||
writeName(image, section + 16, segmentName);
|
||||
if (wide) {
|
||||
write64(image, section + 40, blob.length, little);
|
||||
write32(image, section + 48, fileOffset, little);
|
||||
} else {
|
||||
write32(image, section + 36, blob.length, little);
|
||||
write32(image, section + 40, fileOffset, little);
|
||||
}
|
||||
blob.copy(image, fileOffset);
|
||||
});
|
||||
return Buffer.concat([image, nodeSeaFuseBytes(options.fuse)]);
|
||||
};
|
||||
|
||||
/** One FAT slice wrapping a thin Mach-O SEA image. */
|
||||
export const nodeSeaUniversal = (options: NodeSeaMachOOptions = {}): Buffer => {
|
||||
const slice = nodeSeaMachO(options);
|
||||
const offset = 4096;
|
||||
const fat = Buffer.alloc(offset + slice.length);
|
||||
fat.writeUInt32BE(0xcafebabe, 0);
|
||||
fat.writeUInt32BE(1, 4);
|
||||
fat.writeUInt32BE(0x0100000c, 8);
|
||||
fat.writeUInt32BE(offset, 16);
|
||||
fat.writeUInt32BE(slice.length, 20);
|
||||
fat.writeUInt32BE(12, 24);
|
||||
slice.copy(fat, offset);
|
||||
return fat;
|
||||
};
|
||||
|
||||
export const nodeSeaElf = (
|
||||
options: NodeSeaCarrierOptions & {
|
||||
readonly ownership?: "program" | "section" | "both";
|
||||
} = {},
|
||||
): Buffer => {
|
||||
const notes = Buffer.concat(
|
||||
carrierBlobs(options).map((blob) => elfNote("NODE_SEA_BLOB", 0, blob)),
|
||||
);
|
||||
return Buffer.concat([
|
||||
elfNoteFixture({
|
||||
notes,
|
||||
ownership: options.ownership ?? "both",
|
||||
type: 2,
|
||||
}),
|
||||
nodeSeaFuseBytes(options.fuse),
|
||||
]);
|
||||
};
|
||||
|
||||
export const nodeSeaPe = (options: NodeSeaCarrierOptions = {}): Buffer => {
|
||||
const resources = carrierBlobs(options).map((blob, index) => ({
|
||||
type: 10,
|
||||
name: "NODE_SEA_BLOB",
|
||||
language: index,
|
||||
payload: blob,
|
||||
}));
|
||||
return Buffer.concat([
|
||||
peResourceFixture(true, resources).bytes,
|
||||
nodeSeaFuseBytes(options.fuse),
|
||||
]);
|
||||
};
|
||||
|
||||
const carrierBlobs = (options: NodeSeaCarrierOptions): Buffer[] => [
|
||||
options.blob ?? buildNodeSeaBlob(options),
|
||||
...(options.extraBlobs ?? []),
|
||||
];
|
||||
|
||||
const writeName = (bytes: Buffer, offset: number, name: string): void => {
|
||||
bytes.write(name, offset, Math.min(name.length, 16), "latin1");
|
||||
};
|
||||
|
||||
const write32 = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
value: number,
|
||||
little: boolean,
|
||||
): void => {
|
||||
if (little) bytes.writeUInt32LE(value, offset);
|
||||
else bytes.writeUInt32BE(value, offset);
|
||||
};
|
||||
|
||||
const write64 = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
value: number,
|
||||
little: boolean,
|
||||
): void => {
|
||||
if (little) bytes.writeBigUInt64LE(BigInt(value), offset);
|
||||
else bytes.writeBigUInt64BE(BigInt(value), offset);
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
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"));
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
|
||||
import { assertArtifactReadActive } from "../ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
|
||||
/** Node `SeaBlob::kMagic` from `src/node_sea.h` (v24). */
|
||||
export const NODE_SEA_MAGIC = 0x143da20;
|
||||
|
||||
/** In-memory cap for one injected blob. Larger declarations fail closed. */
|
||||
export const NODE_SEA_MAX_BLOB_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
const FLAG_DISABLE_EXPERIMENTAL_SEA_WARNING = 1 << 0;
|
||||
const FLAG_USE_SNAPSHOT = 1 << 1;
|
||||
const FLAG_USE_CODE_CACHE = 1 << 2;
|
||||
const FLAG_INCLUDE_ASSETS = 1 << 3;
|
||||
const FLAG_INCLUDE_EXEC_ARGV = 1 << 4;
|
||||
const KNOWN_FLAGS =
|
||||
FLAG_DISABLE_EXPERIMENTAL_SEA_WARNING |
|
||||
FLAG_USE_SNAPSHOT |
|
||||
FLAG_USE_CODE_CACHE |
|
||||
FLAG_INCLUDE_ASSETS |
|
||||
FLAG_INCLUDE_EXEC_ARGV;
|
||||
|
||||
// Resource bounds checked before records are created, not Node format limits.
|
||||
// Asset names are budgeted separately because empty bodies make each record
|
||||
// cost far more retained memory than its blob bytes.
|
||||
const MAX_ASSET_COUNT = 100_000;
|
||||
const MAX_ASSET_NAME_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_EXEC_ARGV = 4096;
|
||||
const MAX_EXEC_ARGV_TEXT_BYTES = 1024 * 1024;
|
||||
const NO_OVERRIDE = "This release has no runtime override.";
|
||||
|
||||
export const NODE_SEA_BAD_MAGIC = "Node SEA blob magic is not 0x143da20";
|
||||
export const NODE_SEA_TRUNCATED = "Node SEA blob is truncated";
|
||||
export const NODE_SEA_UNKNOWN_FLAGS =
|
||||
"Node SEA blob flags contain unknown bits";
|
||||
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 =
|
||||
"Node SEA ELF note descriptor is truncated";
|
||||
export const NODE_SEA_ELF_TABLE_TRUNCATED =
|
||||
"Node SEA ELF note table is truncated";
|
||||
export const NODE_SEA_MACHO_COMMANDS_TRUNCATED =
|
||||
"Node SEA Mach-O load commands are truncated";
|
||||
export const NODE_SEA_MACHO_SEGMENT_TRUNCATED =
|
||||
"Node SEA Mach-O segment table is truncated";
|
||||
export const NODE_SEA_FUSE_ABSENT =
|
||||
"Node SEA blob is present but no NODE_SEA_FUSE sentinel was observed";
|
||||
export const NODE_SEA_FUSE_UNSET =
|
||||
"Node SEA blob is present but the NODE_SEA_FUSE sentinel is 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}`;
|
||||
export const NODE_SEA_EXEC_ARGV_TEXT = `Node SEA exec argv text exceeds the ${String(MAX_EXEC_ARGV_TEXT_BYTES)}-byte bound. ${NO_OVERRIDE}`;
|
||||
export const NODE_SEA_BLOB_TOO_LARGE = `Node SEA blob exceeds the ${String(NODE_SEA_MAX_BLOB_BYTES)}-byte in-memory bound`;
|
||||
|
||||
export const nodeSeaAssetCollision = (name: string): string =>
|
||||
`Node SEA asset name collides: ${name}`;
|
||||
|
||||
const FUSE_PREFIX = "NODE_SEA_FUSE_";
|
||||
|
||||
export interface NodeSeaByteRange {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
export interface NodeSeaAsset {
|
||||
readonly name: string;
|
||||
readonly range: NodeSeaByteRange;
|
||||
}
|
||||
|
||||
export type NodeSeaExecArgvExtension = "none" | "env" | "cli";
|
||||
|
||||
/** Decoded Node 24 SEA blob. Ranges are relative to the start of the blob. */
|
||||
export interface DecodedNodeSeaBlob {
|
||||
readonly magic: typeof NODE_SEA_MAGIC;
|
||||
readonly flags: number;
|
||||
readonly disableExperimentalSeaWarning: boolean;
|
||||
readonly useSnapshot: boolean;
|
||||
readonly useCodeCache: boolean;
|
||||
readonly includeAssets: boolean;
|
||||
readonly includeExecArgv: boolean;
|
||||
readonly execArgvExtension: NodeSeaExecArgvExtension;
|
||||
readonly codePath: string;
|
||||
readonly mainScript: NodeSeaByteRange | null;
|
||||
readonly snapshot: NodeSeaByteRange | null;
|
||||
readonly codeCache: NodeSeaByteRange | null;
|
||||
readonly assets: readonly NodeSeaAsset[];
|
||||
readonly execArgv: readonly string[];
|
||||
}
|
||||
|
||||
export interface NodeSeaFuseObservation {
|
||||
readonly fuseState: "set" | "unset" | "absent" | "conflicting";
|
||||
readonly fuseSentinel: string | null;
|
||||
readonly fuseContradiction: string | null;
|
||||
}
|
||||
|
||||
const limitFailure = (
|
||||
message: string,
|
||||
boundary: "capacity" | "memory",
|
||||
): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("limit", message, { boundary });
|
||||
|
||||
const formatFailure = (
|
||||
message: string,
|
||||
cause?: unknown,
|
||||
): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure(
|
||||
"format",
|
||||
message,
|
||||
cause === undefined ? undefined : { cause },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decode one SEA blob the way Node 24's `SeaDeserializer` reads it.
|
||||
* Lengths are 64-bit little-endian `size_t` values. Trailing zero padding is
|
||||
* admitted; any other trailing byte fails closed. 32-bit `size_t` producers
|
||||
* are outside this reader.
|
||||
*/
|
||||
export const decodeNodeSeaBlob = async (
|
||||
bytes: Buffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DecodedNodeSeaBlob> => {
|
||||
assertArtifactReadActive(signal);
|
||||
const cursor = new BlobCursor(bytes);
|
||||
if (cursor.remaining < 4) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const magic = cursor.u32();
|
||||
if (magic !== NODE_SEA_MAGIC) throw formatFailure(NODE_SEA_BAD_MAGIC);
|
||||
if (cursor.remaining < 5) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const flags = cursor.u32();
|
||||
if ((flags & ~KNOWN_FLAGS) !== 0) throw formatFailure(NODE_SEA_UNKNOWN_FLAGS);
|
||||
const execArgvExtension = extensionName(cursor.u8());
|
||||
const codePath = cursor.text(false, NODE_SEA_EMPTY_CODE_PATH);
|
||||
const useSnapshot = (flags & FLAG_USE_SNAPSHOT) !== 0;
|
||||
const body = cursor.byteRange();
|
||||
const useCodeCache = (flags & FLAG_USE_CODE_CACHE) !== 0;
|
||||
const codeCache = useCodeCache ? cursor.byteRange() : null;
|
||||
const includeAssets = (flags & FLAG_INCLUDE_ASSETS) !== 0;
|
||||
const assets = includeAssets ? await cursor.assets(signal) : [];
|
||||
const includeExecArgv = (flags & FLAG_INCLUDE_EXEC_ARGV) !== 0;
|
||||
const execArgv = includeExecArgv ? await cursor.execArgv(signal) : [];
|
||||
if (await cursor.hasNonZeroTail(signal))
|
||||
throw formatFailure(NODE_SEA_TRAILING_BYTES);
|
||||
return {
|
||||
magic: NODE_SEA_MAGIC,
|
||||
flags,
|
||||
disableExperimentalSeaWarning:
|
||||
(flags & FLAG_DISABLE_EXPERIMENTAL_SEA_WARNING) !== 0,
|
||||
useSnapshot,
|
||||
useCodeCache,
|
||||
includeAssets,
|
||||
includeExecArgv,
|
||||
execArgvExtension,
|
||||
codePath,
|
||||
mainScript: useSnapshot ? null : body,
|
||||
snapshot: useSnapshot ? body : null,
|
||||
codeCache,
|
||||
assets,
|
||||
execArgv,
|
||||
};
|
||||
};
|
||||
|
||||
/** Classify every observed `NODE_SEA_FUSE_<32 lowercase hex>:[01]` sentinel. */
|
||||
export const observeNodeSeaFuseMatches = (
|
||||
matches: readonly string[],
|
||||
): NodeSeaFuseObservation => {
|
||||
const unique = [...new Set(matches)];
|
||||
if (unique.length === 0)
|
||||
return {
|
||||
fuseState: "absent",
|
||||
fuseSentinel: null,
|
||||
fuseContradiction: NODE_SEA_FUSE_ABSENT,
|
||||
};
|
||||
const hashes = new Set(
|
||||
unique.map((item) =>
|
||||
item.slice(FUSE_PREFIX.length, FUSE_PREFIX.length + 32),
|
||||
),
|
||||
);
|
||||
const digits = new Set(unique.map((item) => item.at(-1)));
|
||||
if (hashes.size !== 1 || digits.size !== 1)
|
||||
return {
|
||||
fuseState: "conflicting",
|
||||
fuseSentinel: null,
|
||||
fuseContradiction: NODE_SEA_FUSE_CONFLICT,
|
||||
};
|
||||
const sentinel = unique[0];
|
||||
if (sentinel === undefined) throw formatFailure(NODE_SEA_FUSE_CONFLICT);
|
||||
if (digits.has("1"))
|
||||
return {
|
||||
fuseState: "set",
|
||||
fuseSentinel: sentinel,
|
||||
fuseContradiction: null,
|
||||
};
|
||||
return {
|
||||
fuseState: "unset",
|
||||
fuseSentinel: sentinel,
|
||||
fuseContradiction: NODE_SEA_FUSE_UNSET,
|
||||
};
|
||||
};
|
||||
|
||||
const extensionName = (value: number): NodeSeaExecArgvExtension => {
|
||||
if (value === 0) return "none";
|
||||
if (value === 1) return "env";
|
||||
if (value === 2) return "cli";
|
||||
throw formatFailure(NODE_SEA_UNKNOWN_EXTENSION);
|
||||
};
|
||||
|
||||
/** Sequential little-endian reader over one owned blob buffer. */
|
||||
class BlobCursor {
|
||||
#offset = 0;
|
||||
|
||||
constructor(private readonly bytes: Buffer) {}
|
||||
|
||||
get remaining(): number {
|
||||
return this.bytes.length - this.#offset;
|
||||
}
|
||||
|
||||
u32(): number {
|
||||
return this.#demand(4).readUInt32LE(0);
|
||||
}
|
||||
|
||||
u8(): number {
|
||||
return this.#demand(1).readUInt8(0);
|
||||
}
|
||||
|
||||
/** `charge` sees the raw byte length before decoding allocates a string. */
|
||||
text(
|
||||
allowEmpty: boolean,
|
||||
emptyMessage: string,
|
||||
charge?: (bytes: number) => void,
|
||||
): string {
|
||||
const length = this.#size();
|
||||
charge?.(length);
|
||||
const raw = this.#demand(length);
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
raw,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw formatFailure(NODE_SEA_INVALID_UTF8, cause);
|
||||
}
|
||||
if (!allowEmpty && text.length === 0) throw formatFailure(emptyMessage);
|
||||
return text;
|
||||
}
|
||||
|
||||
byteRange(): NodeSeaByteRange {
|
||||
const length = this.#size();
|
||||
const offset = this.#offset;
|
||||
this.#offset += length;
|
||||
return { offset, length };
|
||||
}
|
||||
|
||||
async assets(signal?: AbortSignal): Promise<NodeSeaAsset[]> {
|
||||
const count = this.#count(MAX_ASSET_COUNT, NODE_SEA_ASSET_COUNT);
|
||||
if (count * 16 > this.remaining) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const seen = new Set<string>();
|
||||
const assets: NodeSeaAsset[] = [];
|
||||
let nameBytes = 0;
|
||||
const charge = (bytes: number): void => {
|
||||
nameBytes += bytes;
|
||||
if (nameBytes > MAX_ASSET_NAME_BYTES)
|
||||
throw limitFailure(NODE_SEA_ASSET_NAME_BYTES, "memory");
|
||||
};
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (index % 256 === 0) {
|
||||
await setImmediate();
|
||||
assertArtifactReadActive(signal);
|
||||
}
|
||||
const name = this.text(false, NODE_SEA_EMPTY_ASSET_NAME, charge);
|
||||
if (seen.has(name)) throw formatFailure(nodeSeaAssetCollision(name));
|
||||
seen.add(name);
|
||||
assets.push({ name, range: this.byteRange() });
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
async execArgv(signal?: AbortSignal): Promise<string[]> {
|
||||
const count = this.#count(MAX_EXEC_ARGV, NODE_SEA_EXEC_ARGV_COUNT);
|
||||
if (count * 8 > this.remaining) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const values: string[] = [];
|
||||
let textBytes = 0;
|
||||
const charge = (bytes: number): void => {
|
||||
textBytes += bytes;
|
||||
if (textBytes > MAX_EXEC_ARGV_TEXT_BYTES)
|
||||
throw limitFailure(NODE_SEA_EXEC_ARGV_TEXT, "memory");
|
||||
};
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (index % 256 === 0) {
|
||||
await setImmediate();
|
||||
assertArtifactReadActive(signal);
|
||||
}
|
||||
values.push(this.text(true, NODE_SEA_INVALID_UTF8, charge));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
async hasNonZeroTail(signal?: AbortSignal): Promise<boolean> {
|
||||
for (
|
||||
let start = this.#offset;
|
||||
start < this.bytes.length;
|
||||
start += 64 * 1024
|
||||
) {
|
||||
await setImmediate();
|
||||
assertArtifactReadActive(signal);
|
||||
const end = Math.min(start + 64 * 1024, this.bytes.length);
|
||||
for (let index = start; index < end; index += 1)
|
||||
if (this.bytes[index] !== 0) return true;
|
||||
}
|
||||
assertArtifactReadActive(signal);
|
||||
return false;
|
||||
}
|
||||
|
||||
#size(): number {
|
||||
if (this.remaining < 8) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const value = this.#demand(8).readBigUInt64LE(0);
|
||||
if (
|
||||
value > BigInt(Number.MAX_SAFE_INTEGER) ||
|
||||
value > BigInt(this.remaining)
|
||||
)
|
||||
throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
#count(limit: number, message: string): number {
|
||||
if (this.remaining < 8) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const value = this.#demand(8).readBigUInt64LE(0);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER))
|
||||
throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const count = Number(value);
|
||||
if (count > limit) throw limitFailure(message, "capacity");
|
||||
return count;
|
||||
}
|
||||
|
||||
#demand(length: number): Buffer {
|
||||
if (length > this.remaining) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
const start = this.#offset;
|
||||
this.#offset += length;
|
||||
return this.bytes.subarray(start, this.#offset);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parsePeResources } from "../../native/pe/PeResourceParser.js";
|
||||
import {
|
||||
hasPeResourceName,
|
||||
readPeResourceLayout,
|
||||
} from "../../native/pe/PeResourceLayout.js";
|
||||
import {
|
||||
MAX_INSPECT_PE_FILE_BYTES,
|
||||
MAX_INSPECT_PE_DIRECTORY_ENTRIES,
|
||||
} from "../../domain/native/peResources.js";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
|
||||
import { abortIfNeeded } from "../ArtifactHash.js";
|
||||
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
||||
import { readFatContainerHeader } from "../../domain/apple/machoContainer.js";
|
||||
import { readElfFixedHeader } from "../../domain/elfFixedHeader.js";
|
||||
import {
|
||||
elfNoteAlignment,
|
||||
elfNoteLayout,
|
||||
} from "../../domain/native/elfNoteLayout.js";
|
||||
import {
|
||||
NODE_SEA_BLOB_TOO_LARGE,
|
||||
NODE_SEA_DUPLICATE_BLOB,
|
||||
NODE_SEA_ELF_DESCRIPTOR_TRUNCATED,
|
||||
NODE_SEA_ELF_TABLE_TRUNCATED,
|
||||
NODE_SEA_MACHO_COMMANDS_TRUNCATED,
|
||||
NODE_SEA_MACHO_SEGMENT_TRUNCATED,
|
||||
NODE_SEA_MAX_BLOB_BYTES,
|
||||
NODE_SEA_TRUNCATED,
|
||||
observeNodeSeaFuseMatches,
|
||||
type NodeSeaFuseObservation,
|
||||
} from "./NodeSeaBlob.js";
|
||||
|
||||
const LC_SEGMENT = 0x1;
|
||||
const LC_SEGMENT_64 = 0x19;
|
||||
const PT_NOTE = 4;
|
||||
const SHT_NOTE = 7;
|
||||
const RT_RCDATA = 10;
|
||||
const RESOURCE_NAME = "NODE_SEA_BLOB";
|
||||
const SEA_SECTION = "__NODE_SEA_BLOB";
|
||||
const MAX_COMMAND_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_ELF_HEADERS = 4096;
|
||||
const MAX_NOTE_NAME_BYTES = 4096;
|
||||
const FUSE_LENGTH = "NODE_SEA_FUSE_".length + 32 + 2;
|
||||
// PE header structures must fit this prefix for the bounded name probe.
|
||||
const PE_HEADER_PROBE_BYTES = 64 * 1024;
|
||||
|
||||
const THIN_MAGICS = new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe]);
|
||||
const FAT_MAGICS = new Set([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca]);
|
||||
|
||||
/** One injected blob inside a native carrier. Offsets are carrier file offsets. */
|
||||
export interface NodeSeaLocation {
|
||||
readonly carrier: "mach-o" | "elf" | "pe";
|
||||
readonly blobOffset: number;
|
||||
readonly blobLength: number;
|
||||
}
|
||||
|
||||
interface BlobRange {
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
interface Carrier {
|
||||
readonly handle: FileHandle;
|
||||
readonly fileSize: number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
}
|
||||
|
||||
const formatFailure = (message: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("format", message);
|
||||
|
||||
const limitFailure = (message: string): ArtifactReaderFailure =>
|
||||
new ArtifactReaderFailure("limit", message, { boundary: "memory" });
|
||||
|
||||
/**
|
||||
* Find a postject `NODE_SEA_BLOB` slot. Mach-O accepts segment `NODE_SEA`
|
||||
* (the postject name) and `__NODE_SEA`. ELF matches the full note owner
|
||||
* `NODE_SEA_BLOB` in `PT_NOTE` and `SHT_NOTE`. PE matches RCDATA type 10
|
||||
* named `NODE_SEA_BLOB`. Tables that cannot be parsed before that name is
|
||||
* confirmed leave the carrier unrecognized. A confirmed slot that is
|
||||
* truncated, ambiguous, or declared twice fails closed.
|
||||
*/
|
||||
export const locateNodeSea = async (
|
||||
handle: FileHandle,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NodeSeaLocation | undefined> => {
|
||||
if (!Number.isSafeInteger(fileSize) || fileSize < 4) return undefined;
|
||||
const carrier: Carrier = { handle, fileSize, signal };
|
||||
const prefix = await readExact(carrier, 0, Math.min(fileSize, 64));
|
||||
if (prefix === undefined || prefix.length < 4) return undefined;
|
||||
const magic = prefix.readUInt32BE(0);
|
||||
if (THIN_MAGICS.has(magic) || FAT_MAGICS.has(magic)) {
|
||||
const ranges = THIN_MAGICS.has(magic)
|
||||
? await locateThinMachO(carrier, 0, fileSize)
|
||||
: await locateFatMachO(carrier, prefix);
|
||||
return collapseRanges(carrier, "mach-o", ranges);
|
||||
}
|
||||
if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
|
||||
return collapseRanges(
|
||||
carrier,
|
||||
"elf",
|
||||
await locateElfNotes(carrier, prefix),
|
||||
);
|
||||
if (prefix[0] === 0x4d && prefix[1] === 0x5a)
|
||||
return collapseRanges(carrier, "pe", await locatePeResources(carrier));
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Read a located blob, refusing declarations above the in-memory cap. */
|
||||
export const readNodeSeaBlob = async (
|
||||
handle: FileHandle,
|
||||
location: NodeSeaLocation,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> => {
|
||||
if (location.blobLength > NODE_SEA_MAX_BLOB_BYTES)
|
||||
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
|
||||
const bytes = await readExact(
|
||||
{ handle, fileSize, signal },
|
||||
location.blobOffset,
|
||||
location.blobLength,
|
||||
);
|
||||
if (bytes === undefined) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream the carrier for `NODE_SEA_FUSE_<hash>:[01]`. The blob bytes are
|
||||
* included, so a script that embeds the sentinel text is observed too.
|
||||
*/
|
||||
export const scanNodeSeaFuse = async (
|
||||
handle: FileHandle,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NodeSeaFuseObservation> => {
|
||||
const carrier: Carrier = { handle, fileSize, signal };
|
||||
const matches = new Set<string>();
|
||||
const overlap = FUSE_LENGTH - 1;
|
||||
let carry: Buffer = Buffer.alloc(0);
|
||||
let position = 0;
|
||||
while (position < fileSize) {
|
||||
const length = Math.min(64 * 1024, fileSize - position);
|
||||
const chunk = await readExact(carrier, position, length);
|
||||
if (chunk === undefined)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
"Node SEA carrier shrank during the fuse scan",
|
||||
);
|
||||
const window = carry.length === 0 ? chunk : Buffer.concat([carry, chunk]);
|
||||
for (const match of window
|
||||
.toString("latin1")
|
||||
.matchAll(/NODE_SEA_FUSE_[0-9a-f]{32}:[01]/gu))
|
||||
if (match[0] !== undefined) {
|
||||
matches.add(match[0]);
|
||||
// Two distinct sentinels already establish conflict. The public fact
|
||||
// needs no unbounded set of all sentinel-like strings in asset bytes.
|
||||
if (matches.size > 1) return observeNodeSeaFuseMatches([...matches]);
|
||||
}
|
||||
carry = window.subarray(Math.max(0, window.length - overlap));
|
||||
position += length;
|
||||
}
|
||||
return observeNodeSeaFuseMatches([...matches]);
|
||||
};
|
||||
|
||||
const collapseRanges = async (
|
||||
carrier: Carrier,
|
||||
kind: NodeSeaLocation["carrier"],
|
||||
ranges: readonly BlobRange[],
|
||||
): Promise<NodeSeaLocation | undefined> => {
|
||||
const unique: BlobRange[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const range of ranges) {
|
||||
const key = `${String(range.offset)}:${String(range.length)}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(range);
|
||||
}
|
||||
const chosen = unique[0];
|
||||
if (chosen === undefined) return undefined;
|
||||
if (unique.length > 1) {
|
||||
const hashes = new Set<string>();
|
||||
for (const range of unique)
|
||||
hashes.add(await digestRange(carrier, range.offset, range.length));
|
||||
if (hashes.size !== 1) throw formatFailure(NODE_SEA_DUPLICATE_BLOB);
|
||||
}
|
||||
return {
|
||||
carrier: kind,
|
||||
blobOffset: chosen.offset,
|
||||
blobLength: chosen.length,
|
||||
};
|
||||
};
|
||||
|
||||
const locateFatMachO = async (
|
||||
carrier: Carrier,
|
||||
prefix: Buffer,
|
||||
): Promise<BlobRange[]> => {
|
||||
let bytes = prefix;
|
||||
let header = readFatContainerHeader(bytes, carrier.fileSize);
|
||||
if (header.state === "needs-data") {
|
||||
const required = await readExact(
|
||||
carrier,
|
||||
0,
|
||||
Math.min(carrier.fileSize, header.requiredBytes),
|
||||
);
|
||||
if (required === undefined || required.length < header.requiredBytes)
|
||||
return [];
|
||||
bytes = required;
|
||||
header = readFatContainerHeader(bytes, carrier.fileSize);
|
||||
}
|
||||
if (header.state !== "parsed") {
|
||||
if (bytes.length >= 4 && THIN_MAGICS.has(bytes.readUInt32BE(0)))
|
||||
return locateThinMachO(carrier, 0, carrier.fileSize);
|
||||
return [];
|
||||
}
|
||||
const found: BlobRange[] = [];
|
||||
for (const [index, slice] of header.slices.entries()) {
|
||||
if (slice === undefined || header.rangeIssues[index] != null) continue;
|
||||
found.push(...(await locateThinMachO(carrier, slice.offset, slice.size)));
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const locateThinMachO = async (
|
||||
carrier: Carrier,
|
||||
base: number,
|
||||
sliceSize: number,
|
||||
): Promise<BlobRange[]> => {
|
||||
if (
|
||||
!Number.isSafeInteger(base) ||
|
||||
!Number.isSafeInteger(sliceSize) ||
|
||||
base < 0 ||
|
||||
sliceSize < 32 ||
|
||||
base > carrier.fileSize ||
|
||||
sliceSize > carrier.fileSize - base
|
||||
)
|
||||
return [];
|
||||
const header = await readExact(carrier, base, 32);
|
||||
if (header === undefined || header.length < 28) return [];
|
||||
const magic = header.readUInt32BE(0);
|
||||
if (!THIN_MAGICS.has(magic)) return [];
|
||||
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
|
||||
const wide = magic === 0xfeedfacf || magic === 0xcffaedfe;
|
||||
const headerSize = wide ? 32 : 28;
|
||||
if (header.length < headerSize) return [];
|
||||
const ncmds = readU32(header, 16, little);
|
||||
const sizeofcmds = readU32(header, 20, little);
|
||||
if (
|
||||
sizeofcmds > MAX_COMMAND_BYTES ||
|
||||
headerSize > sliceSize ||
|
||||
sizeofcmds > sliceSize - headerSize
|
||||
)
|
||||
return [];
|
||||
const commands = await readExact(carrier, base + headerSize, sizeofcmds);
|
||||
if (commands === undefined) return [];
|
||||
const found: BlobRange[] = [];
|
||||
let offset = 0;
|
||||
let seen = 0;
|
||||
while (offset + 8 <= commands.length && seen < ncmds) {
|
||||
abortIfNeeded(carrier.signal);
|
||||
const cmd = readU32(commands, offset, little);
|
||||
const cmdsize = readU32(commands, offset + 4, little);
|
||||
if (cmdsize < 8 || offset + cmdsize > commands.length) {
|
||||
if (found.length > 0)
|
||||
throw formatFailure(NODE_SEA_MACHO_COMMANDS_TRUNCATED);
|
||||
return found;
|
||||
}
|
||||
if (cmd === LC_SEGMENT || cmd === LC_SEGMENT_64) {
|
||||
const segment64 = cmd === LC_SEGMENT_64;
|
||||
const segmentHeader = segment64 ? 72 : 56;
|
||||
const sectionSize = segment64 ? 80 : 68;
|
||||
const name = cString(commands, offset + 8, 16);
|
||||
if (
|
||||
(name === "NODE_SEA" || name === "__NODE_SEA") &&
|
||||
cmdsize >= segmentHeader
|
||||
) {
|
||||
const nsects = readU32(
|
||||
commands,
|
||||
offset + (segment64 ? 64 : 48),
|
||||
little,
|
||||
);
|
||||
const table = offset + segmentHeader;
|
||||
if (
|
||||
nsects > Math.floor((cmdsize - segmentHeader) / sectionSize) ||
|
||||
table + nsects * sectionSize > offset + cmdsize
|
||||
)
|
||||
throw formatFailure(NODE_SEA_MACHO_SEGMENT_TRUNCATED);
|
||||
for (let index = 0; index < nsects; index += 1) {
|
||||
const section = table + index * sectionSize;
|
||||
if (cString(commands, section, 16) !== SEA_SECTION) continue;
|
||||
const length = segment64
|
||||
? readU64(commands, section + 40, little)
|
||||
: readU32(commands, section + 36, little);
|
||||
const fileOffset = readU32(
|
||||
commands,
|
||||
section + (segment64 ? 48 : 40),
|
||||
little,
|
||||
);
|
||||
if (length === undefined) throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
|
||||
if (length > NODE_SEA_MAX_BLOB_BYTES)
|
||||
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
|
||||
if (fileOffset > sliceSize || length > sliceSize - fileOffset)
|
||||
throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
found.push({ offset: base + fileOffset, length });
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += cmdsize;
|
||||
seen += 1;
|
||||
}
|
||||
if (found.length > 0 && (seen !== ncmds || offset !== commands.length))
|
||||
throw formatFailure(NODE_SEA_MACHO_COMMANDS_TRUNCATED);
|
||||
return found;
|
||||
};
|
||||
|
||||
const locateElfNotes = async (
|
||||
carrier: Carrier,
|
||||
prefix: Buffer,
|
||||
): Promise<BlobRange[]> => {
|
||||
const header = readElfFixedHeader(prefix, carrier.fileSize);
|
||||
if (!header.ok) return [];
|
||||
const little = header.value.byteOrder === "little";
|
||||
const elf64 = header.value.elfClass === 64;
|
||||
const fields = await readExact(
|
||||
carrier,
|
||||
0,
|
||||
Math.min(carrier.fileSize, header.value.headerSize),
|
||||
);
|
||||
if (fields === undefined) return [];
|
||||
const phoff = elf64
|
||||
? readU64(fields, 32, little)
|
||||
: readU32(fields, 28, little);
|
||||
const shoff = elf64
|
||||
? readU64(fields, 40, little)
|
||||
: readU32(fields, 32, little);
|
||||
const phentsize = readU16(fields, elf64 ? 54 : 42, little);
|
||||
const phnum = readU16(fields, elf64 ? 56 : 44, little);
|
||||
const shentsize = readU16(fields, elf64 ? 58 : 46, little);
|
||||
const shnum = readU16(fields, elf64 ? 60 : 48, little);
|
||||
const minimumProgram = elf64 ? 56 : 32;
|
||||
const minimumSection = elf64 ? 64 : 40;
|
||||
const found: BlobRange[] = [];
|
||||
let opaque = false;
|
||||
const programs = await readElfTables(carrier, {
|
||||
offset: phoff,
|
||||
count: phnum,
|
||||
entrySize: phentsize,
|
||||
minimumEntry: minimumProgram,
|
||||
});
|
||||
if (programs === undefined) opaque = phnum > 0;
|
||||
else
|
||||
for (const entry of programs) {
|
||||
if (readU32(entry, 0, little) !== PT_NOTE) continue;
|
||||
const note = elf64
|
||||
? {
|
||||
offset: readU64(entry, 8, little),
|
||||
length: readU64(entry, 32, little),
|
||||
align: readU64(entry, 48, little),
|
||||
}
|
||||
: {
|
||||
offset: readU32(entry, 4, little),
|
||||
length: readU32(entry, 16, little),
|
||||
align: readU32(entry, 28, little),
|
||||
};
|
||||
const scanned = await scanElfNoteTable(
|
||||
carrier,
|
||||
note,
|
||||
little,
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
else found.push(...scanned);
|
||||
}
|
||||
// The runtime locates the blob through PT_NOTE segments. Injectors can
|
||||
// leave stale or misaligned SHT_NOTE entries, so section notes are only a
|
||||
// fallback when no program-header table is readable.
|
||||
if (programs !== undefined && phnum > 0) {
|
||||
if (found.length > 0 && opaque)
|
||||
throw formatFailure(NODE_SEA_ELF_TABLE_TRUNCATED);
|
||||
return found;
|
||||
}
|
||||
const sections = await readElfTables(carrier, {
|
||||
offset: shoff,
|
||||
count: shnum,
|
||||
entrySize: shentsize,
|
||||
minimumEntry: minimumSection,
|
||||
});
|
||||
if (sections === undefined) opaque = opaque || shnum > 0;
|
||||
else
|
||||
for (const entry of sections) {
|
||||
if (readU32(entry, 4, little) !== SHT_NOTE) continue;
|
||||
const note = elf64
|
||||
? {
|
||||
offset: readU64(entry, 24, little),
|
||||
length: readU64(entry, 32, little),
|
||||
align: readU64(entry, 48, little),
|
||||
}
|
||||
: {
|
||||
offset: readU32(entry, 16, little),
|
||||
length: readU32(entry, 20, little),
|
||||
align: readU32(entry, 32, little),
|
||||
};
|
||||
const scanned = await scanElfNoteTable(
|
||||
carrier,
|
||||
note,
|
||||
little,
|
||||
found.length > 0,
|
||||
);
|
||||
if (scanned === undefined) opaque = true;
|
||||
else found.push(...scanned);
|
||||
}
|
||||
if (found.length > 0 && opaque)
|
||||
throw formatFailure(NODE_SEA_ELF_TABLE_TRUNCATED);
|
||||
return found;
|
||||
};
|
||||
|
||||
const readElfTables = async (
|
||||
carrier: Carrier,
|
||||
table: {
|
||||
readonly offset: number | undefined;
|
||||
readonly count: number;
|
||||
readonly entrySize: number;
|
||||
readonly minimumEntry: number;
|
||||
},
|
||||
): Promise<Buffer[] | undefined> => {
|
||||
if (table.count === 0) return [];
|
||||
if (
|
||||
table.offset === undefined ||
|
||||
table.count > MAX_ELF_HEADERS ||
|
||||
table.entrySize < table.minimumEntry ||
|
||||
table.entrySize > 1024 ||
|
||||
table.offset > carrier.fileSize
|
||||
)
|
||||
return undefined;
|
||||
const entries: Buffer[] = [];
|
||||
for (let index = 0; index < table.count; index += 1) {
|
||||
if (index % 128 === 0) abortIfNeeded(carrier.signal);
|
||||
const offset = table.offset + index * table.entrySize;
|
||||
if (
|
||||
offset > carrier.fileSize ||
|
||||
table.entrySize > carrier.fileSize - offset
|
||||
)
|
||||
return undefined;
|
||||
const entry = await readExact(carrier, offset, table.entrySize);
|
||||
if (entry === undefined) return undefined;
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const scanElfNoteTable = async (
|
||||
carrier: Carrier,
|
||||
note: {
|
||||
readonly offset: number | undefined;
|
||||
readonly length: number | undefined;
|
||||
readonly align: number | undefined;
|
||||
},
|
||||
little: boolean,
|
||||
alreadyFound: boolean,
|
||||
): Promise<BlobRange[] | undefined> => {
|
||||
if (
|
||||
note.offset === undefined ||
|
||||
note.length === undefined ||
|
||||
note.offset > carrier.fileSize ||
|
||||
note.length > carrier.fileSize - note.offset
|
||||
)
|
||||
return undefined;
|
||||
const alignment = elfNoteAlignment(note.align === 8 ? 8 : 4);
|
||||
const found: BlobRange[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor + 12 <= note.length) {
|
||||
abortIfNeeded(carrier.signal);
|
||||
const header = await readExact(carrier, note.offset + cursor, 12);
|
||||
if (header === undefined)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
const namesz = readU32(header, 0, little);
|
||||
const descsz = readU32(header, 4, little);
|
||||
if (namesz > MAX_NOTE_NAME_BYTES)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
if (cursor > note.length - 12 || namesz > note.length - cursor - 12)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
let layout: ReturnType<typeof elfNoteLayout>;
|
||||
try {
|
||||
layout = elfNoteLayout(namesz, descsz, alignment);
|
||||
} catch {
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(layout.descriptor) ||
|
||||
!Number.isSafeInteger(layout.next) ||
|
||||
layout.next < 12
|
||||
)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
const name = await readExact(carrier, note.offset + cursor + 12, namesz);
|
||||
if (name === undefined)
|
||||
return failNoteTable(alreadyFound || found.length > 0);
|
||||
const owner = cString(name, 0, name.length);
|
||||
const descriptorFits =
|
||||
layout.descriptor <= note.length - cursor &&
|
||||
descsz <= note.length - cursor - layout.descriptor;
|
||||
if (owner === RESOURCE_NAME) {
|
||||
if (!descriptorFits)
|
||||
throw formatFailure(NODE_SEA_ELF_DESCRIPTOR_TRUNCATED);
|
||||
if (descsz > NODE_SEA_MAX_BLOB_BYTES)
|
||||
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
|
||||
const blobOffset = note.offset + cursor + layout.descriptor;
|
||||
if (
|
||||
blobOffset > carrier.fileSize ||
|
||||
descsz > carrier.fileSize - blobOffset
|
||||
)
|
||||
throw formatFailure(NODE_SEA_ELF_DESCRIPTOR_TRUNCATED);
|
||||
found.push({ offset: blobOffset, length: descsz });
|
||||
}
|
||||
if (layout.next > note.length - cursor)
|
||||
return failNoteTable(
|
||||
alreadyFound || found.length > 0 || owner === RESOURCE_NAME,
|
||||
);
|
||||
cursor += layout.next;
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const failNoteTable = (confirmed: boolean): undefined => {
|
||||
if (confirmed) throw formatFailure(NODE_SEA_ELF_TABLE_TRUNCATED);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const locatePeResources = async (carrier: Carrier): Promise<BlobRange[]> => {
|
||||
// Probe for the NODE_SEA_BLOB name first so a malformed resource tree in an
|
||||
// ordinary PE stays a native-classification concern, not a SEA failure.
|
||||
const headers = await readExact(
|
||||
carrier,
|
||||
0,
|
||||
Math.min(carrier.fileSize, PE_HEADER_PROBE_BYTES),
|
||||
);
|
||||
if (
|
||||
headers === undefined ||
|
||||
!(await hasPeResourceName(
|
||||
headers,
|
||||
carrier.fileSize,
|
||||
(offset, length) => readExact(carrier, offset, length),
|
||||
RT_RCDATA,
|
||||
RESOURCE_NAME,
|
||||
MAX_INSPECT_PE_DIRECTORY_ENTRIES,
|
||||
))
|
||||
)
|
||||
return [];
|
||||
if (carrier.fileSize > MAX_INSPECT_PE_FILE_BYTES) {
|
||||
throw limitFailure(
|
||||
"Node SEA PE inspection exceeds the 512 MiB PE parser input ceiling",
|
||||
);
|
||||
}
|
||||
const bytes = await readExact(carrier, 0, carrier.fileSize);
|
||||
if (bytes === undefined) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
// A native-looking file need not be an admitted PE image. Once the layout
|
||||
// is admitted, resource errors remain failures rather than inferred absence.
|
||||
try {
|
||||
readPeResourceLayout(bytes);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "format")
|
||||
return [];
|
||||
throw cause;
|
||||
}
|
||||
const resources = await parsePeResources(
|
||||
bytes,
|
||||
{
|
||||
path: "<admitted SEA carrier>",
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
},
|
||||
{
|
||||
path: "/admitted-sea-carrier",
|
||||
max_file_bytes: MAX_INSPECT_PE_FILE_BYTES,
|
||||
max_entries: MAX_INSPECT_PE_DIRECTORY_ENTRIES,
|
||||
},
|
||||
carrier.signal,
|
||||
);
|
||||
return resources.resources.flatMap((resource) => {
|
||||
if (
|
||||
resource.type.kind !== "id" ||
|
||||
resource.type.id !== RT_RCDATA ||
|
||||
resource.name.kind !== "name" ||
|
||||
resource.name.name !== RESOURCE_NAME
|
||||
)
|
||||
return [];
|
||||
const range = resource.payload.location;
|
||||
if (range.bytes > NODE_SEA_MAX_BLOB_BYTES)
|
||||
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
|
||||
return [{ offset: range.offset, length: range.bytes }];
|
||||
});
|
||||
};
|
||||
|
||||
const digestRange = async (
|
||||
carrier: Carrier,
|
||||
offset: number,
|
||||
length: number,
|
||||
): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
let remaining = length;
|
||||
let position = offset;
|
||||
while (remaining > 0) {
|
||||
const chunkLength = Math.min(64 * 1024, remaining);
|
||||
const chunk = await readExact(carrier, position, chunkLength);
|
||||
if (chunk === undefined) throw formatFailure(NODE_SEA_TRUNCATED);
|
||||
hash.update(chunk);
|
||||
position += chunkLength;
|
||||
remaining -= chunkLength;
|
||||
}
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
const readExact = async (
|
||||
carrier: Carrier,
|
||||
offset: number,
|
||||
length: number,
|
||||
): Promise<Buffer | undefined> => {
|
||||
abortIfNeeded(carrier.signal);
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset > carrier.fileSize ||
|
||||
length > carrier.fileSize - offset
|
||||
)
|
||||
return undefined;
|
||||
if (length === 0) return Buffer.alloc(0);
|
||||
const bytes = Buffer.alloc(length);
|
||||
let filled = 0;
|
||||
while (filled < length) {
|
||||
abortIfNeeded(carrier.signal);
|
||||
const read = await carrier.handle.read(
|
||||
bytes,
|
||||
filled,
|
||||
length - filled,
|
||||
offset + filled,
|
||||
);
|
||||
abortIfNeeded(carrier.signal);
|
||||
if (read.bytesRead === 0) return undefined;
|
||||
filled += read.bytesRead;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const cString = (bytes: Buffer, offset: number, length: number): string => {
|
||||
let end = offset;
|
||||
const limit = Math.min(bytes.length, offset + length);
|
||||
while (end < limit && bytes[end] !== 0) end += 1;
|
||||
return bytes.toString("latin1", offset, end);
|
||||
};
|
||||
|
||||
const readU16 = (bytes: Buffer, offset: number, little: boolean): number =>
|
||||
little ? bytes.readUInt16LE(offset) : bytes.readUInt16BE(offset);
|
||||
|
||||
const readU32 = (bytes: Buffer, offset: number, little: boolean): number =>
|
||||
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
|
||||
|
||||
const readU64 = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
little: boolean,
|
||||
): number | undefined => {
|
||||
if (offset < 0 || offset > bytes.length - 8) return undefined;
|
||||
const value = little
|
||||
? bytes.readBigUInt64LE(offset)
|
||||
: bytes.readBigUInt64BE(offset);
|
||||
if (value > BigInt(Number.MAX_SAFE_INTEGER)) return undefined;
|
||||
return Number(value);
|
||||
};
|
||||
@@ -81,12 +81,12 @@ const artifact = <
|
||||
export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
artifact(
|
||||
"inspect_artifact",
|
||||
"Inspect an active archive or application package in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. For PCK and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.",
|
||||
"Inspect an active archive or application package, including Node.js SEA carriers, in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. For PCK and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.",
|
||||
artifactInspectionInputSchema,
|
||||
),
|
||||
artifact(
|
||||
"extract_artifact",
|
||||
"Extract regular files exposed by a supported active archive reader into a fresh temporary directory chosen by REA. A nested archive exposed as a file is copied as a file; inspect and extract it separately to unpack its contents. The result includes its location. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and returns each mismatch as an explicitly untrusted integrity contradiction.",
|
||||
"Extract regular files (including Node.js SEA scripts and assets) exposed by a supported active archive reader into a fresh temporary directory chosen by REA. A nested archive exposed as a file is copied as a file; inspect and extract it separately to unpack its contents. The result includes its location. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files. Rejects traversal and symlink escapes, never overwrites, and verifies cleanup. Integrity mismatches fail by default; record-and-continue extracts the observed bytes and returns each mismatch as an explicitly untrusted integrity contradiction.",
|
||||
artifactExtractionInputSchema,
|
||||
),
|
||||
artifact(
|
||||
|
||||
@@ -92,6 +92,7 @@ const subjectFormatSchema = z.enum([
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
]);
|
||||
// Snapshot entries retain the same raw-only architecture boundary as targets.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
|
||||
@@ -264,7 +264,8 @@ export const projectArtifactInventoryView = (
|
||||
const includeHashCounts =
|
||||
view.kind === "inventory" ||
|
||||
inventory.manifest.root_format === "godot-pck" ||
|
||||
inventory.manifest.root_format === "pyinstaller";
|
||||
inventory.manifest.root_format === "pyinstaller" ||
|
||||
inventory.manifest.root_format === "node-sea";
|
||||
const counts = inventoryCounts(source);
|
||||
const limitations = uniqueSorted([
|
||||
...parent.limitations,
|
||||
|
||||
@@ -89,6 +89,39 @@ export type PyInstallerEntryMetadata = z.infer<
|
||||
typeof pyInstallerEntryMetadataSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Node.js single-executable blob facts. Lengths follow the Node 24 64-bit
|
||||
* little-endian `size_t` reader. The fuse sentinel is a separate carrier scan.
|
||||
*/
|
||||
export const nodeSeaMetadataSchema = z.strictObject({
|
||||
kind: z.literal("node-sea"),
|
||||
magic: z.literal(0x143da20),
|
||||
flags: uint32,
|
||||
disable_experimental_sea_warning: z.boolean(),
|
||||
use_snapshot: z.boolean(),
|
||||
use_code_cache: z.boolean(),
|
||||
include_assets: z.boolean(),
|
||||
include_exec_argv: z.boolean(),
|
||||
exec_argv_extension: z.enum(["none", "env", "cli"]),
|
||||
exec_argv: z.array(z.string()),
|
||||
code_path: z.string(),
|
||||
carrier: z.enum(["mach-o", "elf", "pe"]),
|
||||
blob_offset: offset,
|
||||
blob_length: offset,
|
||||
fuse_state: z.enum(["set", "unset", "absent", "conflicting"]),
|
||||
fuse_sentinel: z.string().nullable(),
|
||||
fuse_contradiction: z.string().nullable(),
|
||||
});
|
||||
export type NodeSeaMetadata = z.infer<typeof nodeSeaMetadataSchema>;
|
||||
|
||||
/** One injected script, snapshot, code cache, or asset. Bytes stay opaque. */
|
||||
export const nodeSeaEntryMetadataSchema = z.strictObject({
|
||||
kind: z.literal("node-sea-entry"),
|
||||
role: z.enum(["main-script", "snapshot", "code-cache", "asset"]),
|
||||
source_path: z.string(),
|
||||
index: uint32,
|
||||
});
|
||||
|
||||
/** Declaration identity that is stable across archive layout changes. */
|
||||
export const pyInstallerEntryDeclaration = (
|
||||
metadata: PyInstallerEntryMetadata,
|
||||
@@ -104,6 +137,8 @@ export const artifactFormatMetadataSchema = z.discriminatedUnion("kind", [
|
||||
godotPckEntryMetadataSchema,
|
||||
pyInstallerMetadataSchema,
|
||||
pyInstallerEntryMetadataSchema,
|
||||
nodeSeaMetadataSchema,
|
||||
nodeSeaEntryMetadataSchema,
|
||||
]);
|
||||
export type ArtifactFormatMetadata = z.infer<
|
||||
typeof artifactFormatMetadataSchema
|
||||
|
||||
@@ -85,6 +85,7 @@ const artifactFormatSchema = z.enum([
|
||||
"directory",
|
||||
"file",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type ArtifactInventoryResult,
|
||||
} from "./artifactGraph.js";
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import { isJavaScriptSourcePath } from "./javascript/javascriptArtifactFiles.js";
|
||||
import { uniqueSorted } from "./canonicalOrdering.js";
|
||||
import { evidenceSchema, parseEvidence, type Evidence } from "./evidence.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
@@ -298,6 +299,24 @@ const probesFor = (
|
||||
inventory: ArtifactInventoryResult,
|
||||
): ArtifactInspectionResult["next_probes"] => {
|
||||
const format = inventory.manifest.root_format;
|
||||
// The JavaScript analyzer selects extracted files by source suffix, so a
|
||||
// main script such as `main` has no follow-up it can read.
|
||||
if (format === "node-sea")
|
||||
return inventory.occurrences.some(
|
||||
(row) =>
|
||||
row.format_metadata?.kind === "node-sea-entry" &&
|
||||
row.format_metadata.role === "main-script" &&
|
||||
isJavaScriptSourcePath(row.logical_path),
|
||||
)
|
||||
? [
|
||||
{
|
||||
operation: "analyze_javascript_application",
|
||||
rationale:
|
||||
"Extract the SEA main script with extract_artifact, then analyze the extracted JavaScript application.",
|
||||
authority: "ast-static-analysis",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return format === "directory"
|
||||
? directoryProbes(inventory)
|
||||
: probesForFormat(format);
|
||||
|
||||
@@ -73,6 +73,7 @@ const subjectFormatSchema = z.enum([
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
]);
|
||||
const subjectBaseSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
|
||||
@@ -8,13 +8,19 @@ export const requirePeRange = (
|
||||
bytes: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
): void => requirePeExtent(bytes.length, offset, length);
|
||||
|
||||
const requirePeExtent = (
|
||||
fileSize: number,
|
||||
offset: number,
|
||||
length: number,
|
||||
): void => {
|
||||
if (
|
||||
!Number.isSafeInteger(offset) ||
|
||||
!Number.isSafeInteger(length) ||
|
||||
offset < 0 ||
|
||||
length < 0 ||
|
||||
offset > bytes.length - length
|
||||
offset > fileSize - length
|
||||
)
|
||||
peFailure(
|
||||
`PE range ${String(offset)} + ${String(length)} leaves the artifact.`,
|
||||
@@ -51,8 +57,13 @@ const intersectsResource = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Shared PE header admission: signature, optional header, data directories, and a section table that fits inside SizeOfHeaders. */
|
||||
export const readPeHeaders = (bytes: Buffer) => {
|
||||
/**
|
||||
* Shared PE header admission: signature, optional header, data directories,
|
||||
* and a section table that fits inside SizeOfHeaders. `bytes` may be a file
|
||||
* prefix that holds the header structures; file-space ranges are checked
|
||||
* against `fileSize`.
|
||||
*/
|
||||
export const readPeHeaders = (bytes: Buffer, fileSize = bytes.length) => {
|
||||
requirePeRange(bytes, 0, 64);
|
||||
if (bytes.readUInt16LE(0) !== 0x5a4d) peFailure("Expected an MZ PE image.");
|
||||
const pe = bytes.readUInt32LE(60);
|
||||
@@ -76,7 +87,7 @@ export const readPeHeaders = (bytes: Buffer) => {
|
||||
const headerSize = bytes.readUInt32LE(optional + 60);
|
||||
const table = optional + optionalSize;
|
||||
requirePeRange(bytes, table, sectionCount * 40);
|
||||
if (headerSize < table + sectionCount * 40 || headerSize > bytes.length)
|
||||
if (headerSize < table + sectionCount * 40 || headerSize > fileSize)
|
||||
peFailure("Invalid PE SizeOfHeaders.");
|
||||
return {
|
||||
pe,
|
||||
@@ -91,7 +102,10 @@ export const readPeHeaders = (bytes: Buffer) => {
|
||||
};
|
||||
|
||||
/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */
|
||||
export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
export const readPeResourceLayout = (
|
||||
bytes: Buffer,
|
||||
fileSize = bytes.length,
|
||||
) => {
|
||||
const {
|
||||
pe,
|
||||
sectionCount,
|
||||
@@ -101,7 +115,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
directoryCount,
|
||||
headerSize,
|
||||
table,
|
||||
} = readPeHeaders(bytes);
|
||||
} = readPeHeaders(bytes, fileSize);
|
||||
const sections: Section[] = [];
|
||||
for (let index = 0; index < sectionCount; index++) {
|
||||
const at = table + index * 40;
|
||||
@@ -114,7 +128,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
if (section.rva + virtualExtent(section) > 0x100000000)
|
||||
peFailure("PE section RVA range overflows.");
|
||||
if (section.size > 0) {
|
||||
requirePeRange(bytes, section.offset, section.size);
|
||||
requirePeExtent(fileSize, section.offset, section.size);
|
||||
if (section.offset < headerSize)
|
||||
peFailure("PE section raw bytes overlap headers.");
|
||||
}
|
||||
@@ -156,7 +170,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
size > headerSize - rva
|
||||
)
|
||||
peFailure("Ambiguous resource header RVA mapping.");
|
||||
requirePeRange(bytes, rva, size);
|
||||
requirePeExtent(fileSize, rva, size);
|
||||
return rva;
|
||||
}
|
||||
if (candidates.length !== 1)
|
||||
@@ -177,7 +191,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
)
|
||||
peFailure("Resource interval intersects overlapping virtual sections.");
|
||||
const offset = section.offset + within;
|
||||
requirePeRange(bytes, offset, size);
|
||||
requirePeExtent(fileSize, offset, size);
|
||||
return offset;
|
||||
};
|
||||
const directoryAt = optional + directoriesAt + 16;
|
||||
@@ -194,3 +208,64 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
map,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounded admission probe: whether the root type directory holds a `typeId`
|
||||
* subdirectory naming `name`. Reads only the header prefix and the
|
||||
* directories on that path; an unadmitted layout or malformed tree reports
|
||||
* false so callers can leave full resource parsing to confirmed carriers.
|
||||
*/
|
||||
export const hasPeResourceName = async (
|
||||
headers: Buffer,
|
||||
fileSize: number,
|
||||
read: (offset: number, length: number) => Promise<Buffer | undefined>,
|
||||
typeId: number,
|
||||
name: string,
|
||||
maxEntries: number,
|
||||
): Promise<boolean> => {
|
||||
let layout: ReturnType<typeof readPeResourceLayout>;
|
||||
let root: number;
|
||||
try {
|
||||
layout = readPeResourceLayout(headers, fileSize);
|
||||
if (layout.size < 16) return false;
|
||||
root = layout.map(layout.rva, layout.size);
|
||||
} catch (cause) {
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "format")
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
const { size } = layout;
|
||||
const relative = (at: number, length: number) =>
|
||||
at < 0 || at > size - length
|
||||
? Promise.resolve(undefined)
|
||||
: read(root + at, length);
|
||||
let examined = 0;
|
||||
const directory = async (at: number): Promise<Buffer | undefined> => {
|
||||
const header = await relative(at, 16);
|
||||
if (header === undefined) return undefined;
|
||||
const entries = header.readUInt16LE(12) + header.readUInt16LE(14);
|
||||
if (entries > maxEntries - examined) return undefined;
|
||||
examined += entries;
|
||||
return relative(at + 16, entries * 8);
|
||||
};
|
||||
const types = await directory(0);
|
||||
if (types === undefined) return false;
|
||||
const expected = Buffer.from(name, "utf16le");
|
||||
for (let at = 0; at < types.length; at += 8) {
|
||||
if (types.readUInt32LE(at) !== typeId) continue;
|
||||
const target = types.readUInt32LE(at + 4);
|
||||
if ((target & 0x80000000) === 0) continue;
|
||||
const names = await directory(target & 0x7fffffff);
|
||||
if (names === undefined) continue;
|
||||
for (let entry = 0; entry < names.length; entry += 8) {
|
||||
const encoded = names.readUInt32LE(entry);
|
||||
if ((encoded & 0x80000000) === 0) continue;
|
||||
const nameAt = encoded & 0x7fffffff;
|
||||
const length = await relative(nameAt, 2);
|
||||
if (length?.readUInt16LE(0) !== name.length) continue;
|
||||
const candidate = await relative(nameAt + 2, expected.length);
|
||||
if (candidate?.equals(expected)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
buildNodeSeaBlob,
|
||||
nodeSeaElf,
|
||||
nodeSeaMachO,
|
||||
nodeSeaPe,
|
||||
} from "../../src/artifacts/nodeSea/NodeSea.fixture.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
|
||||
import {
|
||||
artifactExtractionResultSchema,
|
||||
artifactInventoryResultSchema,
|
||||
} from "../../src/domain/artifactGraph.js";
|
||||
|
||||
cliTest.for(["elf", "mach-o", "pe"] as const)(
|
||||
"inventories and extracts %s SEA through the compiled CLI",
|
||||
async (carrier, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-cli-");
|
||||
const source = join(root, "application");
|
||||
const build = { elf: nodeSeaElf, "mach-o": nodeSeaMachO, pe: nodeSeaPe }[
|
||||
carrier
|
||||
];
|
||||
const script = 'throw new Error("must not execute");\n';
|
||||
await writeFile(
|
||||
source,
|
||||
build({
|
||||
codePath: "main.js",
|
||||
main: script,
|
||||
fuse: "unset",
|
||||
assets: [{ name: "data/hello.txt", body: "hello" }],
|
||||
}),
|
||||
);
|
||||
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.manifest.root_format).toBe("node-sea");
|
||||
expect(graph.coverage.status).toBe("complete");
|
||||
expect(
|
||||
graph.occurrences.find((row) => row.logical_path === ".")
|
||||
?.format_metadata,
|
||||
).toMatchObject({
|
||||
kind: "node-sea",
|
||||
carrier,
|
||||
fuse_state: "unset",
|
||||
include_assets: true,
|
||||
});
|
||||
expect(graph.limitations.join(" ")).toContain("sentinel is unset");
|
||||
expect(
|
||||
graph.occurrences.find((row) => row.logical_path === "main.js"),
|
||||
).toMatchObject({
|
||||
artifact_format: "javascript-bundle",
|
||||
hash_status: "verified",
|
||||
});
|
||||
expect(inspection.next_probes.map((probe) => probe.operation)).toContain(
|
||||
"analyze_javascript_application",
|
||||
);
|
||||
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);
|
||||
const result = artifactExtractionResultSchema.parse(
|
||||
parseEvidence(extracted.json).normalized_result,
|
||||
);
|
||||
expect(result.containment_verified).toBe(true);
|
||||
expect(await readFile(join(result.output_root, "main.js"), "utf8")).toBe(
|
||||
script,
|
||||
);
|
||||
expect(
|
||||
await readFile(join(result.output_root, "data/hello.txt"), "utf8"),
|
||||
).toBe("hello");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"offers no JavaScript probe for a main script without a source suffix",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-plain-cli-");
|
||||
const source = join(root, "application");
|
||||
await writeFile(
|
||||
source,
|
||||
nodeSeaElf({ codePath: "main", main: "void 0;\n", fuse: "set" }),
|
||||
);
|
||||
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,
|
||||
);
|
||||
expect(
|
||||
inspection.next_probes.map((probe) => probe.operation),
|
||||
).not.toContain("analyze_javascript_application");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest("rejects a malformed SEA blob through the CLI", async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-invalid-cli-");
|
||||
const source = join(root, "application");
|
||||
await writeFile(source, nodeSeaElf({ blob: Buffer.alloc(16) }));
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", source, "--json"],
|
||||
});
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stdout + result.stderr).toContain("Node SEA blob magic");
|
||||
});
|
||||
|
||||
cliTest(
|
||||
"refuses an empty-body SEA asset table over its record bound",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-sea-limit-cli-");
|
||||
const source = join(root, "application");
|
||||
await writeFile(
|
||||
source,
|
||||
nodeSeaElf({
|
||||
blob: buildNodeSeaBlob({
|
||||
assets: Array.from({ length: 100_001 }, (_, index) => ({
|
||||
name: `a${String(index)}`,
|
||||
body: "",
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", source, "--json"],
|
||||
});
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
expect(result.stdout + result.stderr).toContain(
|
||||
"Node SEA asset count exceeds",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,106 @@
|
||||
import { readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import {
|
||||
nodeSeaPe,
|
||||
buildNodeSeaBlob,
|
||||
} from "../../../src/artifacts/nodeSea/NodeSea.fixture.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { z } from "zod";
|
||||
import { artifactExtractionResultSchema } from "../../../src/domain/artifactGraph.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
|
||||
// The compiled stdio fixture checks advertised schemas against their dialect and
|
||||
// validates actual tool responses, avoiding a second source server module graph.
|
||||
it.each(["full", "compact"] as const)(
|
||||
"exposes SEA facts and extraction through MCP (%s)",
|
||||
async (profile) => {
|
||||
const root = await createTestTempDirectory("rea-sea-mcp-");
|
||||
const path = join(root, "application.exe");
|
||||
await writeFile(
|
||||
path,
|
||||
nodeSeaPe({
|
||||
codePath: "main.js",
|
||||
main: "throw 1;",
|
||||
fuse: "set",
|
||||
assets: [{ name: "data.txt", body: "data" }],
|
||||
}),
|
||||
);
|
||||
const { call } = await connectLocalToolsMcp({
|
||||
REA_MCP_INPUT_SCHEMA_PROFILE: profile,
|
||||
});
|
||||
let outputRoot: string | undefined;
|
||||
try {
|
||||
const opened = await call("open_binary", { path });
|
||||
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
|
||||
const reply = await call("inspect_artifact", { detail: "summary" });
|
||||
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
|
||||
const summary = z
|
||||
.object({
|
||||
parent_evidence_id: z.string(),
|
||||
summary: z.object({ root_format_metadata: z.unknown() }),
|
||||
})
|
||||
.parse(parseEvidence(reply.structuredContent).normalized_result);
|
||||
expect(summary.summary.root_format_metadata).toMatchObject({
|
||||
kind: "node-sea",
|
||||
carrier: "pe",
|
||||
fuse_state: "set",
|
||||
code_path: "main.js",
|
||||
});
|
||||
const retained = await call("inspect_analysis_view", {
|
||||
source: {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: summary.parent_evidence_id,
|
||||
},
|
||||
view: {
|
||||
kind: "inventory",
|
||||
collection: "occurrences",
|
||||
logical_paths: ["main.js"],
|
||||
fields: ["format_metadata"],
|
||||
},
|
||||
});
|
||||
expect(retained.isError, JSON.stringify(retained)).not.toBe(true);
|
||||
expect(JSON.stringify(retained.structuredContent)).toContain(
|
||||
"main-script",
|
||||
);
|
||||
const extracted = await call("extract_artifact", {});
|
||||
expect(extracted.isError, JSON.stringify(extracted)).not.toBe(true);
|
||||
const result = artifactExtractionResultSchema.parse(
|
||||
parseEvidence(extracted.structuredContent).normalized_result,
|
||||
);
|
||||
outputRoot = result.output_root;
|
||||
expect(await readFile(join(result.output_root, "data.txt"), "utf8")).toBe(
|
||||
"data",
|
||||
);
|
||||
await call("close_binary", {});
|
||||
await writeFile(path, nodeSeaPe({ blob: Buffer.alloc(16) }));
|
||||
await call("open_binary", { path });
|
||||
const invalid = await call("inspect_artifact", {});
|
||||
expect(JSON.stringify(parseMcpToolError(invalid))).toContain(
|
||||
"Node SEA blob magic",
|
||||
);
|
||||
await call("close_binary", {});
|
||||
await writeFile(
|
||||
path,
|
||||
nodeSeaPe({
|
||||
blob: buildNodeSeaBlob({
|
||||
assets: Array.from({ length: 100_001 }, (_, index) => ({
|
||||
name: `a${String(index)}`,
|
||||
body: "",
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await call("open_binary", { path });
|
||||
const overLimit = await call("inspect_artifact", {});
|
||||
expect(JSON.stringify(parseMcpToolError(overLimit))).toContain(
|
||||
"Node SEA asset count exceeds",
|
||||
);
|
||||
} finally {
|
||||
if (outputRoot !== undefined)
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user