mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* refactor(domain): give strict UTF-8 decoding one owner with an explicit BOM policy * fix(domain): keep the leading-BOM behaviour every migrated UTF-8 decoder had * refactor(domain): read the domain layer's strict UTF-8 through the BOM-policy owner (#10803) Twelve domain decoders built their own fatal TextDecoder with ignoreBOM: true. They now use strictUtf8Decoder("preserve"), which keeps that behaviour: each reads bytes where a leading U+FEFF is content (binary-format names and fields, JSON and XML read byte-for-byte, recorded bodies, registry seeds whose kept bytes are re-emitted). The counted baseline drops from 74 to 62. * refactor(artifacts): read artifact readers' strict UTF-8 through the BOM-policy owner (#10871) Fourteen artifact reader modules (17 sites) built their own fatal TextDecoder. They now take it from strictUtf8Decoder with the policy each had: every one kept a leading BOM (ignoreBOM: true), so they use "preserve"; JavaScriptArtifactFiles keeps it for JavaScript and HTML entries and strips it otherwise, as before. The strict-utf8-decoder baseline drops those 17 sites. * refactor(domain): drop TextDecoder imports left by the strict UTF-8 migration * refactor(application): read MCP registration metadata through the strict UTF-8 owner * refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner (#10903) * refactor(formats): read binary-format readers' strict UTF-8 through the BOM-policy owner * refactor(formats): drop the model readers' unused TextDecoder imports * refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner (#10935) * refactor(browser): read browser capture and fetch strict UTF-8 through the BOM-policy owner * refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner (#10936) * refactor(native): read LLDB and Mach-O name strict UTF-8 through the BOM-policy owner * refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner (#10937) * refactor(process): read MCP and NDJSON transport frames' strict UTF-8 through the BOM-policy owner * refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner (#10938) * refactor(application): read JSON and file inputs' strict UTF-8 through the BOM-policy owner * refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner (#10939) * refactor(providers): read IDA, Electron, Ghidra and reference strict UTF-8 through the BOM-policy owner * refactor(evm): read the hex bytecode carrier's strict UTF-8 through the owner and refuse new local decoders (#10940) * refactor(evm): read the hex bytecode carrier's strict UTF-8 through the BOM-policy owner * chore(architecture): match local strict UTF-8 decoders in any option order
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { setImmediate } from "node:timers/promises";
|
|
|
|
import { assertArtifactReadActive } from "../ArtifactFileOperation.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import {
|
|
DEFAULT_NODE_SEA_READER_LIMITS,
|
|
type NodeSeaReaderLimits,
|
|
} from "../../domain/artifactReaderLimits.js";
|
|
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
|
|
import { strictUtf8Decoder } from "../../domain/strictUtf8.js";
|
|
|
|
/** Node `SeaBlob::kMagic` from `src/node_sea.h` (v24). */
|
|
const NODE_SEA_MAGIC = 0x143da20;
|
|
|
|
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;
|
|
|
|
const NODE_SEA_BAD_MAGIC = "Node SEA blob magic is not 0x143da20";
|
|
export const NODE_SEA_TRUNCATED = "Node SEA blob is truncated";
|
|
const NODE_SEA_UNKNOWN_FLAGS = "Node SEA blob flags contain unknown bits";
|
|
const NODE_SEA_UNKNOWN_EXTENSION = "Node SEA exec argv extension is unknown";
|
|
const NODE_SEA_TRAILING_BYTES = "Node SEA blob has trailing bytes";
|
|
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_ELF_NOTES_REPEATED =
|
|
"Node SEA ELF note tables declare more notes than the file holds";
|
|
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";
|
|
const NODE_SEA_FUSE_ABSENT =
|
|
"Node SEA blob is present but no NODE_SEA_FUSE sentinel was observed";
|
|
const NODE_SEA_FUSE_UNSET =
|
|
"Node SEA blob is present but the NODE_SEA_FUSE sentinel is unset";
|
|
const NODE_SEA_FUSE_CONFLICT = "Node SEA fuse sentinels disagree";
|
|
const NODE_SEA_EMPTY_CODE_PATH = "Node SEA code path is empty";
|
|
const NODE_SEA_INVALID_UTF8 = "Node SEA string is not valid UTF-8";
|
|
|
|
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 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,
|
|
limits: NodeSeaReaderLimits = DEFAULT_NODE_SEA_READER_LIMITS,
|
|
): Promise<DecodedNodeSeaBlob> => {
|
|
assertArtifactReadActive(signal);
|
|
if (bytes.length > limits.blob_bytes)
|
|
throw artifactReaderLimitFailure(
|
|
"artifact_limits.node_sea.blob_bytes",
|
|
limits.blob_bytes,
|
|
"bytes",
|
|
bytes.length,
|
|
"memory",
|
|
);
|
|
const cursor = new BlobCursor(bytes, limits);
|
|
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,
|
|
private readonly limits: NodeSeaReaderLimits,
|
|
) {}
|
|
|
|
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 = strictUtf8Decoder("preserve").decode(raw);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RangeError)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"Node SEA UTF-8 text exceeds this runtime's string or memory capacity. Admission budgets do not raise runtime representation limits or reserve host memory.",
|
|
{ boundary: "memory", cause },
|
|
);
|
|
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("asset_count", 16);
|
|
const seen = new Set<string>();
|
|
const assets: NodeSeaAsset[] = [];
|
|
let nameBytes = 0;
|
|
const charge = (bytes: number): void => {
|
|
nameBytes += bytes;
|
|
if (nameBytes > this.limits.asset_name_bytes)
|
|
throw artifactReaderLimitFailure(
|
|
"artifact_limits.node_sea.asset_name_bytes",
|
|
this.limits.asset_name_bytes,
|
|
"bytes",
|
|
nameBytes,
|
|
"memory",
|
|
);
|
|
};
|
|
for (let index = 0; index < count; index += 1) {
|
|
if (index % 256 === 0) {
|
|
await setImmediate();
|
|
assertArtifactReadActive(signal);
|
|
}
|
|
const name = this.text(true, NODE_SEA_INVALID_UTF8, charge);
|
|
if (seen.has(name))
|
|
throw formatFailure(`Node SEA asset name collides: ${name}`);
|
|
seen.add(name);
|
|
assets.push({ name, range: this.byteRange() });
|
|
}
|
|
return assets;
|
|
}
|
|
|
|
async execArgv(signal?: AbortSignal): Promise<string[]> {
|
|
const count = this.#count("argument_count", 8);
|
|
const values: string[] = [];
|
|
let textBytes = 0;
|
|
const charge = (bytes: number): void => {
|
|
textBytes += bytes;
|
|
if (textBytes > this.limits.argument_bytes)
|
|
throw artifactReaderLimitFailure(
|
|
"artifact_limits.node_sea.argument_bytes",
|
|
this.limits.argument_bytes,
|
|
"bytes",
|
|
textBytes,
|
|
"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(
|
|
field: "asset_count" | "argument_count",
|
|
minimumRowBytes: number,
|
|
): number {
|
|
if (this.remaining < 8) throw formatFailure(NODE_SEA_TRUNCATED);
|
|
const value = this.#demand(8).readBigUInt64LE(0);
|
|
if (value > BigInt(Math.floor(this.remaining / minimumRowBytes)))
|
|
throw formatFailure(NODE_SEA_TRUNCATED);
|
|
const count = Number(value);
|
|
if (count > this.limits[field])
|
|
throw artifactReaderLimitFailure(
|
|
`artifact_limits.node_sea.${field}`,
|
|
this.limits[field],
|
|
"count",
|
|
count,
|
|
);
|
|
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);
|
|
}
|
|
}
|