feat(analysis): make resource budgets recoverable across CLI and MCP (#9706)

* refactor(analysis): centralize resource budgets and recovery facts

* feat(analysis): expose ASAR and Ghidra runtime admission controls

* feat(artifacts): expose reader admission and preserve limited recognition

* feat(javascript): expose recoverable semantic precision budgets

* feat(web): add recoverable source-map budgets and compact retained views

* feat(web): make historical capture decoding budgets recoverable

* fix(desktop): enforce selected view policies across capture and export

* feat(runtime): make Frida and LLDB retention budgets selectable

* feat(native): expose diagnostic budgets and preserve exhaustion causes

* feat(managed): expose recoverable snapshot and representation budgets

* feat(metadata): make Go and Rust inspection budgets recoverable

* feat(wasm): expose representation and producer admissions

* feat(evm): expose per-call worker resource budgets

* feat(android): make resource and string-pool omissions recoverable

* feat(producers): expose command and recorded-capture budgets

* docs(analysis): record recovery contracts and integrated validation

* fix(docs): link calibration oracle from published verification pages

* fix(docs): restore desktop task anchors and receipt links

* test(analysis): align fixtures with selectable budget contracts

* fix(analysis): remove source-location projector dependency cycle

* test(analysis): refresh budget recovery and verifier regressions

* test(analysis): preserve format and publication recovery expectations

* test(windows): align conformance with owned runtime admission

* fix(desktop): preserve selected alias kinds in exported state

* test(analysis): align provider fixtures with recoverable budgets

* test(desktop): validate emitted alias metadata structurally

* fix(ghidra): classify bounded Windows endpoint refusals

* test(cli): assert selectable artifact admission recovery

* fix(cli): preserve capture provenance in environment builds
This commit is contained in:
morluto authored and GitHub committed 2026-11-10 05:11:20 -05:00
1 parent 55b6d2ca20
commit cb4e5cfbff
515 files changed
+21066 -3637

No files matched your search

@@ -11,6 +11,79 @@ import { dotnetBundleFixture } from "./dotnet/DotnetBundle.fixture.js";
import { denoMachO } from "./deno/DenoStandalone.fixture.js";
import * as denoLocation from "./deno/DenoStandaloneLocation.js";
import * as pckLocation from "./GodotPckLocation.js";
import { resolveArtifactReaderLimits } from "../domain/artifactReaderLimits.js";
import { nodeSeaElf } from "./nodeSea/NodeSea.fixture.js";
it("readmits cached SEA recognition when the accepted policy changes", async () => {
const path = join(
await createTestTempDirectory("rea-probe-policy-"),
"carrier",
);
await writeFile(path, nodeSeaElf({ fuse: "set", ownership: "section" }));
await withOwnedRegularFile(path, {}, async (source) => {
const original = new ArtifactEmbeddedFormatProbes(path, source);
const location = await original.nodeSea();
expect(location).toBeDefined();
const lower = new ArtifactEmbeddedFormatProbes(
path,
source,
undefined,
original.completedFacts(),
resolveArtifactReaderLimits({ node_sea: { blob_bytes: 1 } }),
);
await expect(lower.nodeSea()).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.node_sea.blob_bytes",
effective_value: 1,
},
});
expect(Object.hasOwn(lower.completedFacts(), "nodeSea")).toBe(false);
const restored = new ArtifactEmbeddedFormatProbes(
path,
source,
undefined,
lower.completedFacts(),
resolveArtifactReaderLimits(),
);
expect(await restored.nodeSea()).toEqual(location);
const recognitionLimited = new ArtifactEmbeddedFormatProbes(
path,
source,
undefined,
restored.completedFacts(),
resolveArtifactReaderLimits({
node_sea: { recognition_header_count: 1 },
}),
);
await expect(recognitionLimited.nodeSea()).resolves.toBeUndefined();
expect(Object.hasOwn(recognitionLimited.completedFacts(), "nodeSea")).toBe(
false,
);
expect(
recognitionLimited.completedFacts().nodeSeaRecognitionFailure,
).toMatchObject({
readerLimit: {
parameter: "artifact_limits.node_sea.recognition_header_count",
effective_value: 1,
required_value: 3,
},
});
const recognitionRestored = new ArtifactEmbeddedFormatProbes(
path,
source,
undefined,
recognitionLimited.completedFacts(),
resolveArtifactReaderLimits({
node_sea: { recognition_header_count: 3 },
}),
);
expect(await recognitionRestored.nodeSea()).toEqual(location);
expect(
recognitionRestored.completedFacts().nodeSeaRecognitionFailure,
).toBeUndefined();
});
});
it("distinguishes examined absence from unexamined formats and honors cancellation on a cached result", async () => {
const root = await createTestTempDirectory("rea-embedded-probe-facts-");
+48 -9
View File
@@ -6,6 +6,10 @@ import {
locateDenoStandalone,
type DenoStandaloneProbe,
} from "./deno/DenoStandaloneLocation.js";
import {
resolveArtifactReaderLimits,
type ArtifactReaderLimits,
} from "../domain/artifactReaderLimits.js";
import type {
StableRegularFileDescriptor,
RegularFileState,
@@ -28,13 +32,15 @@ import {
type PyInstallerCookieObservation,
} from "./pyinstaller/CArchiveLayout.js";
/** Completed observations only; omission means unexamined, and null is examined absence. */
/** Completed observations and explicit recognition limits; null alone is examined absence. */
export interface ArtifactEmbeddedFormatProbeFacts {
readonly sourceState: RegularFileState;
readonly readerLimits?: ArtifactReaderLimits;
readonly bunStandalone?: BunStandaloneProbe;
readonly denoStandalone?: DenoStandaloneProbe;
readonly godotPck?: GodotPckLocation | null;
readonly nodeSea?: NodeSeaLocation | null;
readonly nodeSeaRecognitionFailure?: ArtifactReaderFailure;
readonly dotnetBundleHeaderOffset?: number | null;
readonly pyInstaller?: PyInstallerCookieObservation;
}
@@ -48,6 +54,7 @@ export class ArtifactEmbeddedFormatProbes {
private readonly source: StableRegularFileDescriptor,
private readonly signal?: AbortSignal,
previous?: ArtifactEmbeddedFormatProbeFacts,
private readonly limits: ArtifactReaderLimits = resolveArtifactReaderLimits(),
) {
abortIfNeeded(signal);
if (
@@ -58,7 +65,22 @@ export class ArtifactEmbeddedFormatProbes {
"integrity",
`Root artifact changed before inventory: ${path}`,
);
this.#facts = previous ?? { sourceState: regularFileState(source.initial) };
const reusable =
previous !== undefined &&
JSON.stringify(previous.readerLimits) === JSON.stringify(limits);
if (previous !== undefined && !reusable) {
const {
bunStandalone: _bun,
nodeSea: _sea,
nodeSeaRecognitionFailure: _seaLimit,
...facts
} = previous;
this.#facts = { ...facts, readerLimits: limits };
} else
this.#facts = previous ?? {
sourceState: regularFileState(source.initial),
readerLimits: limits,
};
}
async godotPck(prefix?: Uint8Array): Promise<GodotPckLocation | undefined> {
@@ -79,13 +101,29 @@ export class ArtifactEmbeddedFormatProbes {
abortIfNeeded(this.signal);
if (this.#facts.nodeSea !== undefined)
return this.#facts.nodeSea ?? undefined;
const location = await locateNodeSea(
this.source.handle,
this.source.initial.size,
this.signal,
);
this.#facts = { ...this.#facts, nodeSea: location ?? null };
return location;
if (this.#facts.nodeSeaRecognitionFailure !== undefined) return undefined;
try {
const location = await locateNodeSea(
this.source.handle,
this.source.initial.size,
this.signal,
this.limits.node_sea,
);
this.#facts = { ...this.#facts, nodeSea: location ?? null };
return location;
} catch (cause) {
if (
cause instanceof ArtifactReaderFailure &&
(cause.readerLimit?.parameter ===
"artifact_limits.node_sea.recognition_header_count" ||
cause.readerLimit?.parameter ===
"artifact_limits.node_sea.recognition_load_command_bytes")
) {
this.#facts = { ...this.#facts, nodeSeaRecognitionFailure: cause };
return undefined;
}
throw cause;
}
}
async pyInstaller(): Promise<PyInstallerCookie | undefined> {
@@ -131,6 +169,7 @@ export class ArtifactEmbeddedFormatProbes {
this.source.handle,
this.source.initial.size,
this.signal,
this.limits.bun,
);
abortIfNeeded(this.signal);
this.#facts = { ...this.#facts, bunStandalone: observed };
@@ -3,6 +3,78 @@ import { expect, it } from "vitest";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { translateArtifactFailure } from "./ArtifactOperationFailure.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js";
import { constants as bufferConstants } from "node:buffer";
it.each([
{ effective: 1, required: bufferConstants.MAX_STRING_LENGTH + 1 },
{ effective: bufferConstants.MAX_STRING_LENGTH, required: undefined },
])(
"does not recommend a larger budget when no supported increase can repair it: %o",
({ effective, required }) => {
const failure = artifactReaderLimitFailure(
"artifact_limits.godot.directory_bytes",
effective,
"bytes",
required,
);
const projected = projectAnalysisError(
translateArtifactFailure("inspect_artifact", failure),
);
expect(projected).toMatchObject({
retryable: false,
details: {
reader_limit: {
can_increase: false,
maximum: bufferConstants.MAX_STRING_LENGTH,
},
},
});
expect(projected.remediation.action).toContain("No supported increase");
},
);
it("preserves adjustable reader admission facts and gives cleanup priority", () => {
const failure = artifactReaderLimitFailure(
"artifact_limits.node_sea.argument_count",
1,
"count",
2,
);
const adjustable = projectAnalysisError(
translateArtifactFailure("inspect_artifact", failure),
);
expect(adjustable).toMatchObject({
code: "truncated",
retryable: true,
details: {
reader_limit: {
parameter: "artifact_limits.node_sea.argument_count",
effective_value: 1,
required_value: 2,
unit: "count",
applies_at: "next_operation",
rea_restart_required: false,
},
},
});
expect(adjustable.remediation.action).toContain(
"retry the same complete artifact",
);
const wrapped = ArtifactReaderFailure.withCleanup(failure, {
reason: "Reader descriptor release failed",
resources: ["owned input descriptor"],
});
const projected = projectAnalysisError(
translateArtifactFailure("inspect_artifact", wrapped),
);
expect(projected.retryable).toBe(false);
expect(projected.details).toMatchObject({
reader_limit: adjustable.details?.reader_limit,
cleanup: "incomplete",
resources: ["owned input descriptor"],
});
});
it.each(["capacity", "file-size", "memory"] as const)(
"retains the producing %s limit through public error projection",
+6 -1
View File
@@ -34,12 +34,17 @@ export const translateArtifactFailure = (
? {}
: { admission: cause.admission }),
...(cause.boundary === undefined ? {} : { boundary: cause.boundary }),
...(cause.readerLimit === undefined
? {}
: { readerLimit: cause.readerLimit }),
},
);
if (cause.admission !== undefined)
error.bindLimit({
...analysisSettingLimit(
"asar.max_header_bytes",
cause.admission.kind === "asar-header"
? "asar.max_header_bytes"
: "asar.max_listing_bytes",
cause.admission.maximumBytes,
cause.admission.source,
),
+27 -8
View File
@@ -104,6 +104,11 @@ export class ArtifactPathRegistry {
private readonly budget?: {
readonly maxParts: number;
readonly maxNodes: number;
readonly limitFailure?: (
resource: "path_parts" | "path_nodes",
effectiveValue: number,
requiredValue: number,
) => ArtifactReaderFailure;
},
) {}
@@ -136,10 +141,17 @@ export class ArtifactPathRegistry {
#visit(path: string, kind?: DestinationEntryKind): void {
const parts = path.split("/");
if (this.budget !== undefined && parts.length > this.budget.maxParts)
throw artifactFixedLimitFailure(
"Artifact path depth",
this.budget.maxParts,
"path parts",
throw (
this.budget.limitFailure?.(
"path_parts",
this.budget.maxParts,
parts.length,
) ??
artifactFixedLimitFailure(
"Artifact path depth",
this.budget.maxParts,
"path parts",
)
);
if (this.budget !== undefined) {
let observed = this.#root;
@@ -147,10 +159,17 @@ export class ArtifactPathRegistry {
const existing = observed.children.get(part);
if (existing === undefined) {
if (parts.length - index > this.budget.maxNodes - this.#nodeCount)
throw artifactFixedLimitFailure(
"Artifact path tree",
this.budget.maxNodes,
"path nodes",
throw (
this.budget.limitFailure?.(
"path_nodes",
this.budget.maxNodes,
this.#nodeCount + parts.length - index,
) ??
artifactFixedLimitFailure(
"Artifact path tree",
this.budget.maxNodes,
"path nodes",
)
);
break;
}
+20
View File
@@ -1,4 +1,8 @@
import type { EspFlashSelection } from "../domain/firmware/espFlashLocation.js";
import {
resolveArtifactReaderLimits,
type ArtifactReaderLimitsInput,
} from "../domain/artifactReaderLimits.js";
import type { AnalysisSettingsReader } from "../domain/analysisSettings.js";
import { runtimeAsarHeaderAdmission } from "../config/runtimeAnalysisSettings.js";
import { artifactTargetRefusal } from "../domain/apple/artifactTargetApplicability.js";
@@ -348,6 +352,13 @@ class ArtifactClient implements AnalysisClient {
this.settings,
),
integrityPolicy: parsed.integrity_policy,
...(parsed.artifact_limits === undefined
? {}
: {
readerLimits: resolveArtifactReaderLimits(
parsed.artifact_limits,
),
}),
resourceScope: this.#resourceScope,
},
options?.signal,
@@ -487,6 +498,9 @@ class ArtifactClient implements AnalysisClient {
const parsed = artifactInventoryInputSchema.parse(parameters);
const inventoryParameters = {
integrity_policy: parsed.integrity_policy,
...(parsed.artifact_limits === undefined
? {}
: { artifact_limits: parsed.artifact_limits }),
};
await options?.progress?.report({
phase: "inspect_artifact.inventory",
@@ -536,6 +550,7 @@ class ArtifactClient implements AnalysisClient {
private inventory(
parsed: {
readonly integrity_policy: "fail" | "record-and-continue";
readonly artifact_limits?: ArtifactReaderLimitsInput | undefined;
},
options?: ExecutionOptions,
) {
@@ -554,6 +569,11 @@ class ArtifactClient implements AnalysisClient {
espFlashSelection: espFlashSelection(this.target),
...(options?.signal === undefined ? {} : { signal: options.signal }),
integrity: parsed.integrity_policy,
...(parsed.artifact_limits === undefined
? {}
: {
readerLimits: resolveArtifactReaderLimits(parsed.artifact_limits),
}),
});
}
}
+7
View File
@@ -1,4 +1,5 @@
import { fixedAllowanceRemediation } from "../domain/fixedAllowanceRemediation.js";
import type { ArtifactReaderLimitFact } from "../domain/artifactReaderLimits.js";
import type {
ArtifactAdmissionLimit,
ArtifactLimitBoundary,
@@ -37,6 +38,7 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
readonly boundary?: ArtifactLimitBoundary;
/** Named admission whose recovery must not be inferred from other limit text. */
readonly admission?: ArtifactAdmissionLimit;
readonly readerLimit?: ArtifactReaderLimitFact;
}
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
@@ -243,6 +245,7 @@ export class ArtifactReaderFailure extends Error {
readonly boundary: ArtifactLimitBoundary | undefined;
/** Named admission for a limit whose public recovery is owned by the producer. */
readonly admission: ArtifactAdmissionLimit | undefined;
readonly readerLimit: ArtifactReaderLimitFact | undefined;
constructor(
readonly reason:
@@ -271,6 +274,7 @@ export class ArtifactReaderFailure extends Error {
this.partialObservation = options?.partialObservation;
this.boundary = options?.boundary;
this.admission = options?.admission;
this.readerLimit = options?.readerLimit;
}
/** Retain completed facts when a later artifact step fails. */
@@ -326,6 +330,9 @@ export class ArtifactReaderFailure extends Error {
...(primary.admission === undefined
? {}
: { admission: primary.admission }),
...(primary.readerLimit === undefined
? {}
: { readerLimit: primary.readerLimit }),
...(mergedObservation === undefined
? {}
: { partialObservation: mergedObservation }),
+46
View File
@@ -0,0 +1,46 @@
import type { ArtifactLimitBoundary } from "../domain/artifactOperationError.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { artifactReaderLimitMaximum } from "../domain/artifactReaderLimits.js";
/** Construct exact next-call recovery at the enforcing resource owner. */
export const artifactReaderLimitFailure = (
parameter: string,
effectiveValue: number,
unit: "bytes" | "count",
requiredValue?: number | bigint,
boundary: ArtifactLimitBoundary = "capacity",
): ArtifactReaderFailure => {
const required =
typeof requiredValue === "bigint"
? requiredValue <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(requiredValue)
: requiredValue.toString()
: requiredValue;
const maximum = artifactReaderLimitMaximum(parameter, unit);
const canIncrease =
effectiveValue < maximum &&
(requiredValue === undefined ||
(typeof requiredValue === "bigint"
? requiredValue <= BigInt(maximum)
: requiredValue <= maximum));
const recovery = canIncrease
? `Select a larger ${parameter} (1..${String(maximum)} ${unit}) and retry the same complete artifact. Changes apply on the next call without restarting REA.`
: `No supported increase to ${parameter} can admit this observation; the maximum is ${String(maximum)} ${unit}. Keep the complete input and inspect its declared dimensions and validity. If those declarations are valid, use an explicitly selected format-specific reader that supports the required representation.`;
const detail = `Artifact admission reached ${parameter} (${String(effectiveValue)} ${unit})${required === undefined ? "." : `; requires ${String(required)} ${unit}.`} ${recovery} Delivery paging does not raise this producer admission.`;
return new ArtifactReaderFailure("limit", detail, {
boundary,
userMessage: detail,
readerLimit: {
parameter,
effective_value: effectiveValue,
unit,
minimum: 1,
maximum,
...(required === undefined ? {} : { required_value: required }),
applies_at: "next_operation",
agent_editable: true,
can_increase: canIncrease,
rea_restart_required: false,
},
});
};
+5 -1
View File
@@ -79,7 +79,11 @@ export class AsarArtifactReader implements ArtifactReader {
};
assertAsarReadActive(signal);
try {
return await readAsarListing(admitted.header, signal);
return await readAsarListing(
admitted.header,
signal,
this.options.headerAdmission?.listing,
);
} catch (cause: unknown) {
// Listing names members relative to this archive; keep which archive.
if (
+6 -1
View File
@@ -15,6 +15,7 @@ import {
DEFAULT_ASAR_MAX_HEADER_BYTES,
MAXIMUM_ASAR_MAX_HEADER_BYTES,
} from "../domain/analysisResourceLimits.js";
import { parseAsarListingAdmission } from "../config/asarListingAdmission.js";
/**
* Resolve the admission from a composition root's selected environment; an
@@ -28,7 +29,11 @@ export const resolveAsarHeaderAdmission = (
environment[ASAR_HEADER_ADMISSION_SETTING],
);
if (!parsed.ok) throw parsed.error;
return parsed.value;
const listing = parseAsarListingAdmission(
environment.REA_ASAR_MAX_LISTING_BYTES,
);
if (!listing.ok) throw listing.error;
return { ...parsed.value, listing: listing.value };
};
const MEBIBYTE = 1024 * 1024;
+14 -5
View File
@@ -5,9 +5,10 @@ import { asarDeclaredSizeSchema, asarNodeSchema } from "./AsarNode.js";
import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
import { assertAsarReadActive } from "./AsarFailure.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
/** Capacity for expanded paths and listing records, independent of packed bytes. */
const MAX_LISTING_BYTES = 64 * 1024 * 1024;
import {
DEFAULT_ASAR_LISTING_ADMISSION,
type AsarListingAdmission,
} from "../config/asarListingAdmission.js";
const BLOCK_INTEGRITY_LIMITATION =
"ASAR block integrity declarations were not validated. Whole-file digest comparison does not establish block integrity or Electron runtime acceptance.";
/** Source text quoted in listing diagnostics; longer values keep their length. */
@@ -104,6 +105,7 @@ function* ownChildren(files: Record<string, unknown>) {
export const readAsarListing = async (
header: unknown,
signal?: AbortSignal,
admission: AsarListingAdmission = DEFAULT_ASAR_LISTING_ADMISSION,
): Promise<AsarListedEntry[]> => {
const parsedRoot = asarListingNodeSchema.safeParse(header);
if (!parsedRoot.success)
@@ -116,10 +118,17 @@ export const readAsarListing = async (
let retainedBytes = 0;
const admitRetainedBytes = (bytes: number): void => {
retainedBytes += bytes;
if (retainedBytes > MAX_LISTING_BYTES)
if (retainedBytes > admission.maximumBytes)
throw new ArtifactReaderFailure(
"limit",
"ASAR expanded listing exceeds its 64 MiB representation budget",
`ASAR expanded listing exceeds its ${String(admission.maximumBytes)} byte representation budget (source ${admission.source}). On MCP, set asar.max_listing_bytes with update_analysis_settings, then retry the same archive; the change applies to the next operation without restarting REA. For CLI, set REA_ASAR_MAX_LISTING_BYTES in the invocation environment. The budget must be a positive safe integer in bytes. Header admission and worker heaps do not raise this independent retention budget.`,
{
admission: {
kind: "asar-listing",
...admission,
requiredBytes: retainedBytes,
},
},
);
};
let examined = 0;
+49 -23
View File
@@ -1,3 +1,8 @@
import {
DEFAULT_BUN_READER_LIMITS,
type BunReaderLimits,
} from "../domain/artifactReaderLimits.js";
import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js";
import type { BunStandaloneAdmission } from "./bun/BunStandaloneAdmission.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import { setImmediate } from "node:timers/promises";
@@ -30,7 +35,6 @@ import {
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import {
MAX_BUN_RETAINED_BYTES,
bunFormatFailure,
bunMetadataBytesLimit,
probeBunStandaloneGraph,
@@ -44,8 +48,6 @@ import {
const REGIONS = ["contents", "sourcemap", "bytecode", "module-info"] as const;
type BunRegion = (typeof REGIONS)[number];
const MAX_LOGICAL_PATH_PARTS = 256;
const MAX_LOGICAL_PATH_NODES = 250_000;
interface BunModuleDestination {
readonly path: string;
readonly sourcePath?: string;
@@ -72,6 +74,8 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
constructor(
path: string,
admitted?: StableRegularFileDescriptor | BunStandaloneAdmission,
private readonly limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
private readonly expandedBytes?: number,
) {
if (admitted !== undefined && "kind" in admitted) {
this.#admission = admitted;
@@ -229,6 +233,7 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
source.handle,
source.initial.size,
signal,
this.limits,
));
if (probe.status === "absent")
throw bunFormatFailure("no Bun section or trailer");
@@ -239,16 +244,19 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
source.handle,
probe.claim,
signal,
this.limits,
);
if (located.status === "absent")
throw bunFormatFailure("no Bun section or trailer");
if (located.failure !== undefined) throw located.failure;
if (located.graph === undefined)
throw bunFormatFailure("graph data was not admitted");
const decoded = await decodeBunModuleGraph(located.graph);
const decoded = await decodeBunModuleGraph(located.graph, this.limits);
const entryModule = decoded.modules[decoded.entryPointId];
const entryDestination =
entryModule === undefined ? null : logicalModulePath(entryModule);
entryModule === undefined
? null
: logicalModulePath(entryModule, this.limits.path_parts);
const header: BunStandaloneMetadata = {
kind: "bun-standalone",
layout_revision: decoded.revision,
@@ -263,33 +271,45 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
compile_exec_argv_hex: decoded.compileExecArgv.hex,
flags: decoded.flags,
};
let retainedBytes = jsonValueByteLength(header, MAX_BUN_RETAINED_BYTES);
if (retainedBytes > MAX_BUN_RETAINED_BYTES)
throw bunMetadataBytesLimit(retainedBytes);
let retainedBytes = jsonValueByteLength(
header,
this.limits.representation_bytes,
);
if (retainedBytes > this.limits.representation_bytes)
throw bunMetadataBytesLimit(retainedBytes, this.limits);
const entries = new Map<string, ArtifactEntry>();
let declaredPlaintext = 0n;
const expansionBudget = BigInt(
artifactExpansionBudgetBytes(located.graph.payloadLength),
this.expandedBytes ??
artifactExpansionBudgetBytes(located.graph.payloadLength),
);
const paths = new ArtifactPathRegistry({
maxParts: MAX_LOGICAL_PATH_PARTS,
maxNodes: MAX_LOGICAL_PATH_NODES,
maxParts: this.limits.path_parts,
maxNodes: this.limits.path_nodes,
limitFailure: (field, maximum, required) =>
artifactReaderLimitFailure(
`artifact_limits.bun.${field}`,
maximum,
"count",
required,
),
});
for (const module of decoded.modules) {
assertArtifactReadActive(signal);
const destination =
module.index === decoded.entryPointId && entryDestination !== null
? entryDestination
: logicalModulePath(module);
: logicalModulePath(module, this.limits.path_parts);
for (const region of REGIONS) {
const range = regionRange(module, region);
if (range === null) continue;
declaredPlaintext += BigInt(range.length);
if (declaredPlaintext > expansionBudget)
throw new ArtifactReaderFailure(
"limit",
`Bun standalone readable entries declare ${String(declaredPlaintext)} plaintext bytes, exceeding this graph's ${String(expansionBudget)}-byte expansion budget. This release has no runtime override. Host memory and NODE_OPTIONS do not raise it.`,
{ boundary: "capacity" },
throw artifactReaderLimitFailure(
"artifact_limits.expanded_bytes",
Number(expansionBudget),
"bytes",
declaredPlaintext,
);
const entry = moduleEntry(
located.graph,
@@ -301,10 +321,10 @@ export class BunStandaloneArtifactReader implements ArtifactReader {
paths.reservePath(entry.path);
retainedBytes += jsonValueByteLength(
entryMetadataJson(entry),
Math.max(0, MAX_BUN_RETAINED_BYTES - retainedBytes),
Math.max(0, this.limits.representation_bytes - retainedBytes),
);
if (retainedBytes > MAX_BUN_RETAINED_BYTES)
throw bunMetadataBytesLimit(retainedBytes);
if (retainedBytes > this.limits.representation_bytes)
throw bunMetadataBytesLimit(retainedBytes, this.limits);
entries.set(entry.adapterKey, entry);
}
}
@@ -387,20 +407,26 @@ const moduleEntry = (
* `sourcePath`. Unsafe, drive, and invalid UTF-8 names fall back to
* `bun-modules/<index>` so inventory can still retain the module.
*/
const logicalModulePath = (module: BunDecodedModule): BunModuleDestination => {
const fallback = `bun-modules/${String(module.index)}`;
const logicalModulePath = (
module: BunDecodedModule,
maximumParts: number,
): BunModuleDestination => {
const fallback =
maximumParts === 1
? `bun-module-${String(module.index)}`
: `bun-modules/${String(module.index)}`;
const text = module.nameText.text;
if (text === null || text.length === 0) return { path: fallback };
const candidate = text.startsWith("/") ? text.slice(1) : text;
// Preserve virtual source facts without admitting an unbounded logical path tree.
let parts = 1;
for (let index = 0; index < candidate.length; index++)
if (candidate.charCodeAt(index) === 47 && ++parts > MAX_LOGICAL_PATH_PARTS)
if (candidate.charCodeAt(index) === 47 && ++parts > maximumParts)
return {
path: fallback,
sourcePath: text,
limitations: [
`The virtual name exceeds the fixed ${String(MAX_LOGICAL_PATH_PARTS)}-part logical path allowance; an indexed path is used and the original name is retained.`,
`The virtual name exceeds artifact_limits.bun.path_parts (${String(maximumParts)} parts); an indexed path is used and the original name is retained.`,
],
};
@@ -1,6 +1,10 @@
import { Readable } from "node:stream";
import { finished, pipeline } from "node:stream/promises";
import { createInflateRaw } from "node:zlib";
import {
DEFAULT_DOTNET_BUNDLE_READER_LIMITS,
type DotnetBundleReaderLimits,
} from "../domain/artifactReaderLimits.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
@@ -39,6 +43,7 @@ export class DotnetBundleArtifactReader implements ArtifactReader {
constructor(
path: string,
admitted?: StableRegularFileDescriptor | DotnetBundleAdmission,
private readonly limits: DotnetBundleReaderLimits = DEFAULT_DOTNET_BUNDLE_READER_LIMITS,
) {
if (admitted !== undefined && "kind" in admitted) {
this.#admission = admitted;
@@ -83,6 +88,7 @@ export class DotnetBundleArtifactReader implements ArtifactReader {
source.initial.size,
header,
signal,
this.limits,
);
this.#admission = undefined;
}
@@ -21,6 +21,7 @@ import { openRegularFile } from "../filesystem/RegularFile.js";
import type { ArtifactEntry } from "./ArtifactReader.js";
import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js";
import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js";
import { DEFAULT_GODOT_READER_LIMITS } from "../domain/artifactReaderLimits.js";
const writePck = async (bytes: Buffer): Promise<string> => {
const path = join(await createTestTempDirectory("rea-godot-pck-"), "game");
@@ -54,6 +55,79 @@ const members: readonly GodotPckMember[] = [
{ path: "data/é.json", data: '{"session":"Session1"}' },
];
it("recovers the same PCK directory after raising encoded metadata admission", async () => {
const bytes = buildGodotPck(4, [
{ path: "res://main.gd", data: "extends Node\n" },
]);
const path = await writePck(bytes);
const refused = new GodotPckArtifactReader(path, undefined, {
...DEFAULT_GODOT_READER_LIMITS,
directory_bytes: 128,
});
try {
await expect(list(refused)).rejects.toMatchObject({
reason: "limit",
boundary: "memory",
readerLimit: {
parameter: "artifact_limits.godot.directory_bytes",
effective_value: 128,
},
});
} finally {
await refused.close();
}
const recovered = new GodotPckArtifactReader(path, undefined, {
...DEFAULT_GODOT_READER_LIMITS,
directory_bytes: 4096,
});
try {
const entries = await list(recovered);
expect(entries).toMatchObject([{ path: "main.gd", declaredSize: 13 }]);
expect(await text(await recovered.open(entries[0]!))).toBe(
"extends Node\n",
);
} finally {
await recovered.close();
}
});
it("applies selected aggregate work before yielding Godot members", async () => {
const path = await writePck(
buildGodotPck(4, [{ path: "res://main.gd", data: "extends Node\n" }]),
);
const refused = new GodotPckArtifactReader(
path,
undefined,
DEFAULT_GODOT_READER_LIMITS,
12,
);
try {
await expect(list(refused)).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.expanded_bytes",
effective_value: 12,
required_value: 13,
},
});
} finally {
await refused.close();
}
const recovered = new GodotPckArtifactReader(
path,
undefined,
DEFAULT_GODOT_READER_LIMITS,
13,
);
try {
expect(await list(recovered)).toMatchObject([
{ path: "main.gd", declaredSize: 13 },
]);
} finally {
await recovered.close();
}
});
describe("GodotPckArtifactReader format 1", () => {
it.each([false, true])(
"reads Godot 3 format 1 directory and absolute member offsets (embedded=%s)",
+111 -81
View File
@@ -1,4 +1,9 @@
import { readArtifactFileBytes } from "./ArtifactFileRange.js";
import {
DEFAULT_GODOT_READER_LIMITS,
type GodotReaderLimits,
} from "../domain/artifactReaderLimits.js";
import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js";
import { readGodotPckHeaderFacts } from "../domain/godotPckFormat.js";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
import type { FileHandle } from "node:fs/promises";
@@ -24,8 +29,6 @@ const PACK_REL_FILEBASE = 2;
const PACK_SPARSE_BUNDLE = 4;
const PACK_FILE_ENCRYPTED = 1;
const DIRECTORY_CHUNK_BYTES = 64 * 1024;
/** Encoded retained entry metadata, including escaped paths and source facts. */
const MAX_DIRECTORY_METADATA_BYTES = 64 * 1024 * 1024;
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
/** One descriptor, one validated directory, and bounded positional member reads. */
@@ -37,6 +40,8 @@ export class GodotPckArtifactReader extends PositionalArtifactReader {
constructor(
path: string,
admitted?: StableRegularFileDescriptor | GodotPckAdmission,
private readonly limits: GodotReaderLimits = DEFAULT_GODOT_READER_LIMITS,
private readonly expandedBytes?: number,
) {
const admission =
admitted !== undefined && "kind" in admitted ? admitted : undefined;
@@ -79,99 +84,115 @@ export class GodotPckArtifactReader extends PositionalArtifactReader {
handle,
header.directory_start + 4,
header.pack_end,
signal,
{ signal, limits: this.limits },
);
const entries: PositionalArtifactEntry[] = [];
let retainedBytes = 0;
let declaredPlaintextBytes = 0n;
const expansionBudget = BigInt(
artifactExpansionBudgetBytes(header.pack_end - header.pack_start),
this.expandedBytes ??
artifactExpansionBudgetBytes(header.pack_end - header.pack_start),
);
for (let index = 0; index < header.declared_file_count; index++) {
if (index % 256 === 0) await setImmediate();
assertArtifactReadActive(signal);
const pathBytes = await cursor.uint32();
cursor.requireBytes(pathBytes + (header.format_version === 1 ? 32 : 36));
// Check worst-case JSON escaping before decoding or allocating the path.
// The logical path, raw source path, and metadata source path can each
// need six JSON bytes per input byte before retained facts are charged.
if (pathBytes * 18 > MAX_DIRECTORY_METADATA_BYTES - retainedBytes)
throw metadataLimit();
const rawPath = await cursor.bytes(pathBytes);
const offset = await cursor.uint64();
const size = await cursor.uint64();
const md5 = (await cursor.bytes(16)).toString("hex");
// Godot 3's format 1 has neither per-file flags nor a file-base field.
const flags = header.format_version === 1 ? 0 : await cursor.uint32();
if ((flags & ~PACK_FILE_ENCRYPTED) !== 0)
throw new ArtifactReaderFailure(
"unavailable",
`Godot patch or unknown member flags are unsupported: 0x${flags.toString(16)}`,
);
const sourcePath = pckMemberPath(rawPath);
if (header.format_version === 1 && offset === 0n)
throw new ArtifactReaderFailure(
"unavailable",
`Godot PCK patch removal entry is unsupported: ${sourcePath}`,
);
const start = toSafeOffset(
offset,
header.file_base,
header.pack_end,
sourcePath,
const metadata = await this.#readEntry(
cursor,
header,
index,
retainedBytes,
);
if (size > BigInt(header.pack_end - start))
throw formatFailure(`member exceeds the pack: ${sourcePath}`);
// Aliases are valid, but each path causes another hash/copy. Admit the
// complete directory before yielding anything, including all-zero claims.
if ((flags & PACK_FILE_ENCRYPTED) === 0) {
declaredPlaintextBytes += size;
if (!metadata.encrypted) {
declaredPlaintextBytes += BigInt(metadata.declaredSize);
if (declaredPlaintextBytes > expansionBudget)
throw new ArtifactReaderFailure(
"limit",
`Godot PCK readable entries declare ${String(declaredPlaintextBytes)} plaintext bytes, exceeding this pack's ${String(expansionBudget)}-byte expansion budget`,
throw artifactReaderLimitFailure(
"artifact_limits.expanded_bytes",
Number(expansionBudget),
"bytes",
declaredPlaintextBytes,
"memory",
);
}
const metadata = {
path: sourcePath.startsWith("res://")
? sourcePath.slice(6)
: sourcePath,
sourcePath,
kind: "file" as const,
declaredSize: Number(size),
compressedSize: null,
executable: false,
encrypted: (flags & PACK_FILE_ENCRYPTED) !== 0,
byteOffset: start,
declaredIntegrity: /^0{32}$/u.test(md5)
? null
: { algorithm: "md5" as const, digest: md5 },
unpacked: false,
limitations: /^0{32}$/u.test(md5)
? ["Godot PCK declares an all-zero MD5; source integrity is unknown."]
: [],
adapterKey: `godot-pck-entry:${String(index)}`,
formatMetadata: {
kind: "godot-pck-entry" as const,
index,
source_path: sourcePath,
declared_offset: Number(offset),
flags,
md5_status: /^0{32}$/u.test(md5)
? ("all-zero" as const)
: ("declared" as const),
},
} satisfies PositionalArtifactEntry;
retainedBytes += jsonValueByteLength(
metadata,
MAX_DIRECTORY_METADATA_BYTES - retainedBytes,
this.limits.directory_bytes - retainedBytes,
);
if (retainedBytes > MAX_DIRECTORY_METADATA_BYTES) throw metadataLimit();
if (retainedBytes > this.limits.directory_bytes)
throw metadataLimit(this.limits, retainedBytes);
entries.push(metadata);
}
await this.verifyCarrier(signal);
return { entries, coverage: undefined };
}
async #readEntry(
cursor: PckCursor,
header: GodotPckMetadata,
index: number,
retainedBytes: number,
) {
const pathBytes = await cursor.uint32();
cursor.requireBytes(pathBytes + (header.format_version === 1 ? 32 : 36));
// Check worst-case JSON escaping before decoding or allocating the path.
// The logical path, raw source path, and metadata source path can each
// need six JSON bytes per input byte before retained facts are charged.
if (pathBytes * 18 > this.limits.directory_bytes - retainedBytes)
throw metadataLimit(this.limits, retainedBytes + pathBytes * 18);
const rawPath = await cursor.bytes(pathBytes);
const offset = await cursor.uint64();
const size = await cursor.uint64();
const md5 = (await cursor.bytes(16)).toString("hex");
// Godot 3's format 1 has neither per-file flags nor a file-base field.
const flags = header.format_version === 1 ? 0 : await cursor.uint32();
if ((flags & ~PACK_FILE_ENCRYPTED) !== 0)
throw new ArtifactReaderFailure(
"unavailable",
`Godot patch or unknown member flags are unsupported: 0x${flags.toString(16)}`,
);
const sourcePath = pckMemberPath(rawPath);
if (header.format_version === 1 && offset === 0n)
throw new ArtifactReaderFailure(
"unavailable",
`Godot PCK patch removal entry is unsupported: ${sourcePath}`,
);
const start = toSafeOffset(
offset,
header.file_base,
header.pack_end,
sourcePath,
);
if (size > BigInt(header.pack_end - start))
throw formatFailure(`member exceeds the pack: ${sourcePath}`);
return {
path: sourcePath.startsWith("res://") ? sourcePath.slice(6) : sourcePath,
sourcePath,
kind: "file" as const,
declaredSize: Number(size),
compressedSize: null,
executable: false,
encrypted: (flags & PACK_FILE_ENCRYPTED) !== 0,
byteOffset: start,
declaredIntegrity: /^0{32}$/u.test(md5)
? null
: { algorithm: "md5" as const, digest: md5 },
unpacked: false,
limitations: /^0{32}$/u.test(md5)
? ["Godot PCK declares an all-zero MD5; source integrity is unknown."]
: [],
adapterKey: `godot-pck-entry:${String(index)}`,
formatMetadata: {
kind: "godot-pck-entry" as const,
index,
source_path: sourcePath,
declared_offset: Number(offset),
flags,
md5_status: /^0{32}$/u.test(md5)
? ("all-zero" as const)
: ("declared" as const),
},
} satisfies PositionalArtifactEntry;
}
}
/** Decode header fields once; unsupported content does not erase observed headers. */
@@ -273,7 +294,10 @@ class PckCursor {
private readonly handle: FileHandle,
private position: number,
private readonly end: number,
private readonly signal?: AbortSignal,
private readonly options: {
readonly signal: AbortSignal | undefined;
readonly limits: GodotReaderLimits;
},
) {}
requireBytes(length: number): void {
if (length > this.end - this.position)
@@ -281,7 +305,8 @@ class PckCursor {
}
async bytes(length: number): Promise<Buffer> {
this.requireBytes(length);
if (length > MAX_DIRECTORY_METADATA_BYTES / 18) throw metadataLimit();
if (length > this.options.limits.directory_bytes / 18)
throw metadataLimit(this.options.limits, length * 18);
if (length > DIRECTORY_CHUNK_BYTES) {
// A large valid virtual path is bounded by retained representation, not
// the host filesystem's PATH_MAX. Only this path receives a larger read.
@@ -290,7 +315,7 @@ class PckCursor {
this.position,
length,
this.end,
this.signal,
this.options.signal,
);
this.position += length;
return value;
@@ -302,7 +327,7 @@ class PckCursor {
this.position,
Math.min(DIRECTORY_CHUNK_BYTES, this.end - this.position),
this.end,
this.signal,
this.options.signal,
);
this.#bufferStart = this.position;
}
@@ -330,9 +355,14 @@ const toSafeOffset = (
};
const formatFailure = (detail: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("format", `Godot PCK is malformed: ${detail}`);
const metadataLimit = (): ArtifactReaderFailure =>
new ArtifactReaderFailure(
"limit",
"Godot PCK directory exceeds the 64 MiB encoded metadata budget",
{ boundary: "memory" },
const metadataLimit = (
limits: GodotReaderLimits,
required: number,
): ArtifactReaderFailure =>
artifactReaderLimitFailure(
"artifact_limits.godot.directory_bytes",
limits.directory_bytes,
"bytes",
required,
"memory",
);
+195 -1
View File
@@ -3,7 +3,13 @@ import { once } from "node:events";
import { writeFileSync } from "node:fs";
import { buffer } from "node:stream/consumers";
import { createReader } from "./inventory/reader.js";
import { open, readFile, writeFile, type FileHandle } from "node:fs/promises";
import {
open,
readFile,
stat,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import { setImmediate } from "node:timers";
@@ -12,6 +18,7 @@ import { expect, it, vi } from "vitest";
import * as artifactPaths from "./ArtifactPaths.js";
import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js";
import type { ArtifactEntry } from "./ArtifactReader.js";
import { artifactFormatDescriptor } from "./artifactFormats.js";
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
@@ -31,6 +38,8 @@ import { MAX_INSPECT_PE_FILE_BYTES } from "../domain/native/peResources.js";
import { openRegularFile } from "../filesystem/RegularFile.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js";
import { classifyAndHashRootForInventory } from "./inventory/classify.js";
import { DEFAULT_NODE_SEA_READER_LIMITS } from "../domain/artifactReaderLimits.js";
const writeCarrier = async (name: string, bytes: Buffer): Promise<string> => {
const directory = await createTestTempDirectory("rea-node-sea-");
@@ -39,6 +48,14 @@ const writeCarrier = async (name: string, bytes: Buffer): Promise<string> => {
return path;
};
const seaEntries = async (
reader: NodeSeaArtifactReader,
): Promise<ArtifactEntry[]> => {
const entries: ArtifactEntry[] = [];
for await (const entry of reader.entries()) entries.push(entry);
return entries;
};
const script = 'console.log("rea-node-sea")\n';
const populated = {
codePath: "app/main.js",
@@ -50,6 +67,124 @@ const populated = {
fuse: "set" as const,
};
it("rechecks a lower SEA policy instead of reusing an earlier admitted decode", async () => {
const path = await writeCarrier(
"sea-policy",
nodeSeaElf({ execArgv: ["one", "two"], fuse: "set" }),
);
const scope = new ArtifactResourceScope();
try {
await scope.run(async () => {
const classified = await classifyAndHashRootForInventory(
path,
false,
await stat(path),
{ resourceScope: scope },
);
const source = classified.rootSource;
if (source !== undefined) scope.addCloseHook(() => source.owner.close());
const admitted = classified.readerAdmission;
if (
admitted === undefined ||
!("kind" in admitted) ||
admitted.kind !== "node-sea"
)
throw new Error("Expected a production Node SEA admission");
const refused = new NodeSeaArtifactReader(path, admitted, {
...DEFAULT_NODE_SEA_READER_LIMITS,
argument_count: 1,
});
try {
await expect(seaEntries(refused)).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.node_sea.argument_count",
effective_value: 1,
required_value: 2,
},
});
} finally {
await refused.close();
}
const recovered = new NodeSeaArtifactReader(path, admitted, {
...DEFAULT_NODE_SEA_READER_LIMITS,
argument_count: 2,
});
try {
await seaEntries(recovered);
expect(recovered.formatMetadata()?.exec_argv).toEqual(["one", "two"]);
} finally {
await recovered.close();
}
});
} finally {
await scope.close();
}
});
it("locates SEA after a long unrelated ELF note owner without admitting its text", async () => {
const notes = Buffer.concat([
elfNote("vendor".repeat(700), 1, Buffer.from("unrelated")),
elfNote(
"NODE_SEA_BLOB",
0,
buildNodeSeaBlob({ main: script, codePath: "sea.js" }),
),
]);
const path = await writeCarrier(
"long-note-owner",
elfNoteFixture({ notes, ownership: "program", type: 2 }),
);
const snapshot = await scanCanonicalArtifactInventory(path);
expect(snapshot.manifest.root_format).toBe("node-sea");
expect(snapshot.occurrences).toContainEqual(
expect.objectContaining({
logical_path: "sea.js",
declared_size: Buffer.byteLength(script),
}),
);
});
it("applies selected aggregate work before yielding SEA members", async () => {
const path = await writeCarrier(
"sea-expanded",
nodeSeaElf({
main: "hello",
assets: [{ name: "data", body: "world" }],
fuse: "set",
}),
);
const refused = new NodeSeaArtifactReader(
path,
undefined,
DEFAULT_NODE_SEA_READER_LIMITS,
9,
);
try {
await expect(seaEntries(refused)).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.expanded_bytes",
effective_value: 9,
required_value: 10,
},
});
} finally {
await refused.close();
}
const recovered = new NodeSeaArtifactReader(
path,
undefined,
DEFAULT_NODE_SEA_READER_LIMITS,
10,
);
try {
expect(await seaEntries(recovered)).toHaveLength(2);
} finally {
await recovered.close();
}
});
it.each([
["mach-o", nodeSeaMachO(populated)],
["elf", nodeSeaElf(populated)],
@@ -452,6 +587,65 @@ const repeatedNoteElf = (count: number, notes: Buffer): Buffer => {
return bytes;
};
it("reports limited SEA recognition rather than absence above the ELF header policy", async () => {
const blob = buildNodeSeaBlob({ main: script, codePath: "sea.js" });
const bytes = repeatedNoteElf(4097, elfNote("NODE_SEA_BLOB", 0, blob));
// Only the first header claims the note; the others are valid PT_NULL rows.
for (let index = 1; index < 4097; index++)
bytes.writeUInt32LE(0, 64 + index * 56);
const path = await writeCarrier("sea-header-count", bytes);
const handle = await open(path, "r");
try {
await expect(locateNodeSea(handle, bytes.length)).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.node_sea.recognition_header_count",
effective_value: 4096,
required_value: 4097,
},
});
await expect(
locateNodeSea(handle, bytes.length, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
recognition_header_count: 4097,
}),
).resolves.toMatchObject({ carrier: "elf", blobLength: blob.length });
} finally {
await handle.close();
}
});
it("recovers SEA recognition after raising Mach-O load-command admission", async () => {
const bytes = nodeSeaMachO({ main: script, codePath: "sea.js" });
const required = bytes.readUInt32LE(20);
const handle = await open(
await writeCarrier("sea-load-commands", bytes),
"r",
);
try {
await expect(
locateNodeSea(handle, bytes.length, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
recognition_load_command_bytes: required - 1,
}),
).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.node_sea.recognition_load_command_bytes",
required_value: required,
},
});
await expect(
locateNodeSea(handle, bytes.length, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
recognition_load_command_bytes: required,
}),
).resolves.toMatchObject({ carrier: "mach-o" });
} finally {
await handle.close();
}
});
it("reads repeated PT_NOTE declarations at most once per note the file holds", async () => {
// 4 KiB of zero bytes is 341 empty 12-byte notes.
const empty = Buffer.alloc(4096);
+47 -27
View File
@@ -1,4 +1,8 @@
import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js";
import {
DEFAULT_NODE_SEA_READER_LIMITS,
type NodeSeaReaderLimits,
} from "../domain/artifactReaderLimits.js";
import { setImmediate } from "node:timers/promises";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
@@ -8,9 +12,8 @@ import {
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js";
import {
NODE_SEA_BLOB_TOO_LARGE,
NODE_SEA_MAX_BLOB_BYTES,
decodeNodeSeaBlob,
type DecodedNodeSeaBlob,
type NodeSeaByteRange,
@@ -19,7 +22,6 @@ import {
locateNodeSea,
readNodeSeaBlob,
scanNodeSeaFuse,
type NodeSeaLocation,
} from "./nodeSea/NodeSeaLocation.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import {
@@ -44,6 +46,8 @@ export class NodeSeaArtifactReader extends PositionalArtifactReader {
constructor(
path: string,
admitted?: StableRegularFileDescriptor | NodeSeaAdmission,
private readonly limits: NodeSeaReaderLimits = DEFAULT_NODE_SEA_READER_LIMITS,
private readonly expandedBytes?: number,
) {
const admission =
admitted !== undefined && "kind" in admitted ? admitted : undefined;
@@ -66,34 +70,48 @@ export class NodeSeaArtifactReader extends PositionalArtifactReader {
this.#admission = undefined;
}
/** Reuse only the decode admitted under this accepted policy. */
async #readBlob(
source: StableRegularFileDescriptor,
signal?: AbortSignal,
): Promise<Pick<NodeSeaAdmission, "location" | "decoded">> {
const admittedLimits = this.#admission?.limits;
if (
this.#admission !== undefined &&
admittedLimits !== undefined &&
Object.entries(this.limits).every(
([field, value]) => Reflect.get(admittedLimits, field) === value,
)
)
return this.#admission;
const location = await locateNodeSea(
source.handle,
source.initial.size,
signal,
this.limits,
);
if (location === undefined) throw formatFailure("blob was not found");
const decoded = await decodeNodeSeaBlob(
await readNodeSeaBlob(source, location, signal, this.limits),
signal,
this.limits,
);
return { location, decoded };
}
protected override async prepareEntries(
signal?: AbortSignal,
): Promise<PositionalArtifactEntries> {
const { handle, initial } = await this.verifyCarrier(signal);
// Freshness remains owned by ArtifactFileSource even when classification
// already decoded this exact borrowed descriptor's blob.
let location: NodeSeaLocation;
let decoded: DecodedNodeSeaBlob;
if (this.#admission !== undefined) {
({ location, decoded } = this.#admission);
} else {
const found = await locateNodeSea(handle, initial.size, signal);
if (found === undefined) throw formatFailure("blob was not found");
location = found;
if (location.blobLength > NODE_SEA_MAX_BLOB_BYTES)
throw new ArtifactReaderFailure("limit", NODE_SEA_BLOB_TOO_LARGE, {
boundary: "memory",
});
decoded = await decodeNodeSeaBlob(
await readNodeSeaBlob(handle, location, initial.size, signal),
signal,
);
}
const { location, decoded } = await this.#readBlob(
{ handle, initial },
signal,
);
const fuse = await scanNodeSeaFuse(handle, initial.size, signal);
const entries: PositionalArtifactEntry[] = [];
let declaredBytes = 0n;
const expansionBudget = BigInt(
artifactExpansionBudgetBytes(location.blobLength),
this.expandedBytes ?? artifactExpansionBudgetBytes(location.blobLength),
);
let index = 0;
for (const member of seaMembers(decoded)) {
@@ -101,10 +119,12 @@ export class NodeSeaArtifactReader extends PositionalArtifactReader {
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" },
throw artifactReaderLimitFailure(
"artifact_limits.expanded_bytes",
Number(expansionBudget),
"bytes",
declaredBytes,
"memory",
);
const start = location.blobOffset + member.range.offset;
if (start > initial.size || member.range.length > initial.size - start)
+14 -12
View File
@@ -39,11 +39,8 @@ import {
type ArtifactReader,
} from "./ArtifactReader.js";
import type { ZipPackageFormat } from "../domain/zipPackageFormat.js";
import {
ARTIFACT_EXPANSION_RATIO,
artifactExpansionBudgetBytes,
MINIMUM_ARTIFACT_EXPANDED_BYTES,
} from "../domain/artifactExpansionBudget.js";
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
import { artifactReaderLimitFailure } from "./ArtifactReaderLimits.js";
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
import { ArtifactFileSource } from "./ArtifactFileSource.js";
import { readFileHandleRange } from "../filesystem/readFileHandleRange.js";
@@ -189,6 +186,7 @@ export class ZipArtifactReader implements ArtifactReader {
format: ZipPackageFormat,
maximumMetadataReadBytes?: number,
admitted?: StableRegularFileDescriptor,
private readonly expandedBytes?: number,
) {
this.format = format;
this.#source = new NodeFileReader(path, maximumMetadataReadBytes, admitted);
@@ -199,7 +197,7 @@ export class ZipArtifactReader implements ArtifactReader {
this.#assertOpen(signal);
let index = 0;
// A traversal owns its accounting, including overlapping or repeated passes.
let declaredFileBytes = 0;
let declaredFileBytes = 0n;
const enumeration = typedZipFailures(
this.#reader.getEntriesGenerator({ decodeText: decodeZipEntryText }),
);
@@ -220,12 +218,16 @@ export class ZipArtifactReader implements ArtifactReader {
Number.isSafeInteger(metadata.declaredSize) &&
metadata.declaredSize >= 0
) {
declaredFileBytes += metadata.declaredSize;
const budget = artifactExpansionBudgetBytes(this.#source.size);
if (declaredFileBytes > budget)
throw new ArtifactReaderFailure(
"limit",
`ZIP entries declare ${String(declaredFileBytes)} uncompressed bytes, exceeding this archive's ${String(budget)}-byte decompression budget (${String(MINIMUM_ARTIFACT_EXPANDED_BYTES)}-byte minimum, ${String(ARTIFACT_EXPANSION_RATIO)}x archive size); a small archive cannot expand this far`,
declaredFileBytes += BigInt(metadata.declaredSize);
const budget =
this.expandedBytes ??
artifactExpansionBudgetBytes(this.#source.size);
if (declaredFileBytes > BigInt(budget))
throw artifactReaderLimitFailure(
"artifact_limits.expanded_bytes",
budget,
"bytes",
declaredFileBytes,
);
}
const stored = this.#entries.get(metadata.adapterKey);
+25 -3
View File
@@ -49,6 +49,9 @@ import { ZipArtifactReader } from "./ZipArtifactReader.js";
export type AbsentInventory = "platform" | "universal" | "unavailable" | "root";
export interface ArtifactReaderConstruction {
readonly readerLimits?:
| import("../domain/artifactReaderLimits.js").ArtifactReaderLimits
| undefined;
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly unreadableDirectories: "fail" | "omit";
readonly admission?: ArtifactReaderAdmission | undefined;
@@ -162,7 +165,13 @@ const zipDescriptor = (id: ZipPackageFormat) =>
id,
ZIP_FAMILY_CAPABILITIES,
(path, options) =>
new ZipArtifactReader(path, id, undefined, sourceOf(options)),
new ZipArtifactReader(
path,
id,
undefined,
sourceOf(options),
options.readerLimits?.expanded_bytes,
),
);
function described<Id extends ArtifactFormatId>(
@@ -226,6 +235,7 @@ const NON_ZIP_FORMATS = {
new DotnetBundleArtifactReader(
path,
admittedAs("dotnet-bundle", options),
options.readerLimits?.dotnet_bundle,
),
}),
"bun-standalone": container("bun-standalone", {
@@ -235,6 +245,8 @@ const NON_ZIP_FORMATS = {
new BunStandaloneArtifactReader(
path,
admittedAs("bun-standalone", options),
options.readerLimits?.bun,
options.readerLimits?.expanded_bytes,
),
}),
"deno-standalone": container("deno-standalone", {
@@ -292,7 +304,12 @@ const NON_ZIP_FORMATS = {
readerLimitation:
"PCK inventory establishes packaged bytes and metadata; scenes, binary resources, GDScript bytecode and runtime signal behavior were not decoded.",
createReader: (path, options) =>
new GodotPckArtifactReader(path, admittedAs("godot-pck", options)),
new GodotPckArtifactReader(
path,
admittedAs("godot-pck", options),
options.readerLimits?.godot,
options.readerLimits?.expanded_bytes,
),
}),
unityfs: container("unityfs", {
roleContainer: true,
@@ -368,7 +385,12 @@ const NON_ZIP_FORMATS = {
readerLimitation:
"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.",
createReader: (path, options) =>
new NodeSeaArtifactReader(path, admittedAs("node-sea", options)),
new NodeSeaArtifactReader(
path,
admittedAs("node-sea", options),
options.readerLimits?.node_sea,
options.readerLimits?.expanded_bytes,
),
}),
"intel-hex": container("intel-hex", {
roleContainer: true,
+89 -9
View File
@@ -1,10 +1,13 @@
import {
DEFAULT_BUN_READER_LIMITS,
type BunReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import { open, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import {
probeBunStandaloneGraph,
bunModuleCountLimit,
BUN_GRAPH_SUFFIX_BYTES,
} from "./BunModuleGraphLayout.js";
import { decodeBunModuleGraph } from "./BunModuleGraphMembers.js";
@@ -52,7 +55,7 @@ async function locate(bytes: Buffer) {
}
/** Probe while counting bytes read through the handle. */
async function probe(bytes: Buffer) {
async function probe(bytes: Buffer, limits?: BunReaderLimits) {
const path = join(await createTestTempDirectory("rea-bun-parser-"), "app");
await writeFile(path, bytes);
const handle = await open(path, "r");
@@ -70,7 +73,12 @@ async function probe(bytes: Buffer) {
});
try {
return {
result: await probeBunStandaloneGraph(counted, bytes.length),
result: await probeBunStandaloneGraph(
counted,
bytes.length,
undefined,
limits,
),
bytesRead,
};
} finally {
@@ -308,7 +316,7 @@ it.each([
},
);
it("matches ELF section names in bounded time when headers share one long name", async () => {
it("reads a shared long ELF section name once without materializing its string table", async () => {
const shnum = 16_383;
const stringOffset = 64;
const strings = Buffer.alloc(1024 * 1024, 0x61);
@@ -325,9 +333,9 @@ it("matches ELF section names in bounded time when headers share one long name",
bytes.writeBigUInt64LE(BigInt(stringOffset), header + 24);
bytes.writeBigUInt64LE(BigInt(strings.length), header + 32);
}
const started = performance.now();
expect((await probe(bytes)).result).toEqual({ status: "absent" });
expect(performance.now() - started).toBeLessThan(2_000);
const observed = await probe(bytes);
expect(observed.result).toEqual({ status: "absent" });
expect(observed.bytesRead).toBeLessThan(shnum * 64 + 1024);
});
it("refuses an excessive table count before reading or allocating the module table", async () => {
@@ -340,7 +348,6 @@ it("refuses an excessive table count before reading or allocating the module tab
if (result.status !== "present" || result.graph === undefined)
throw new Error("Missing graph");
const read = vi.fn(result.graph.data.read);
const expected = bunModuleCountLimit(count);
await expect(
decodeBunModuleGraph({
...result.graph,
@@ -349,7 +356,80 @@ it("refuses an excessive table count before reading or allocating the module tab
).rejects.toMatchObject({
reason: "limit",
boundary: "capacity",
message: expected.message,
readerLimit: {
parameter: "artifact_limits.bun.module_count",
effective_value: 100_000,
required_value: 100_001,
unit: "count",
},
});
expect(read).not.toHaveBeenCalled();
});
it.each([
{
carrier: "ELF",
encode: elfBunSection,
field: "recognition_table_bytes",
needed: 192,
},
{
carrier: "Mach-O",
encode: machoBunSection,
field: "recognition_load_command_bytes",
needed: 152,
},
] as const)(
"keeps $carrier recognition unknown at its selected boundary and recovers the same graph",
async ({ encode, field, needed }) => {
const bytes = encode(
bunPrefixedPayload(
bunModuleGraphPayload({
modules: [{ name: "main.js", contents: Buffer.from("retained") }],
}),
),
);
const limited = await probe(bytes, {
...DEFAULT_BUN_READER_LIMITS,
[field]: needed - 1,
});
expect(limited.result).toMatchObject({
status: "limited",
failure: {
reason: "limit",
readerLimit: {
parameter: `artifact_limits.bun.${field}`,
effective_value: needed - 1,
required_value: needed,
},
},
});
const recovered = await probe(bytes, {
...DEFAULT_BUN_READER_LIMITS,
[field]: needed,
});
expect(recovered.result).toMatchObject({
status: "present",
claim: {
carrier: encode === elfBunSection ? "elf-section" : "mach-o-section",
},
});
},
);
it("recognizes an ELF Bun name beyond the former string-table prefix without reading the prefix", async () => {
const bytes = elfBunSection(
bunPrefixedPayload(
bunModuleGraphPayload({
modules: [{ name: "main.js", contents: Buffer.from("retained") }],
}),
),
1024 * 1024 + 8,
);
const observed = await probe(bytes);
expect(observed.result).toMatchObject({
status: "present",
claim: { carrier: "elf-section" },
});
expect(observed.bytesRead).toBeLessThan(1024);
});
+133 -106
View File
@@ -8,12 +8,13 @@ import {
} from "../../domain/apple/machoLoadCommands.js";
import type { FileHandle } from "node:fs/promises";
import { fixedAllowanceRemediation } from "../../domain/fixedAllowanceRemediation.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import {
ArtifactReaderFailure,
artifactFixedLimitFailure,
} from "../ArtifactReader.js";
DEFAULT_BUN_READER_LIMITS,
type BunReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
/** Exact trailer written by Bun's standalone module graph serializer. */
export const BUN_TRAILER = Buffer.from("\n---- Bun! ----\n");
@@ -27,20 +28,6 @@ export const BUN_RECORD_BYTES_1_3_3 = 36;
/** Bun 1.3.9 through at least 1.4.4 module records. */
export const BUN_RECORD_BYTES_1_3_9 = 52;
/**
* Module-table admission. This release has no runtime override; host memory
* and NODE_OPTIONS do not raise it.
*/
export const MAX_BUN_MODULE_COUNT = 100_000;
/**
* Serialized graph payload admission, in bytes. A claimed length above this
* ceiling is refused before the payload is read. This release has no runtime
* override; host memory and NODE_OPTIONS do not raise it.
*/
export const MAX_BUN_RETAINED_BYTES = 256 * 1024 * 1024;
const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
const MAX_SECTION_TABLE_BYTES = 1024 * 1024;
const MAX_PE_SECTIONS = 96;
const LC_SEGMENT = 0x1;
const LC_SEGMENT_64 = 0x19;
@@ -91,6 +78,7 @@ export interface BunGraphClaim {
export type BunStandaloneProbe =
| { readonly status: "absent" }
| { readonly status: "limited"; readonly failure: ArtifactReaderFailure }
| {
readonly status: "present";
readonly claim: BunGraphClaim;
@@ -104,6 +92,7 @@ export type BunStandaloneProbe =
export type BunStandaloneRecognition =
| { readonly status: "absent" }
| { readonly status: "limited"; readonly failure: ArtifactReaderFailure }
| {
readonly status: "present";
readonly graph: LocatedBunGraph;
@@ -115,6 +104,13 @@ export type BunStandaloneRecognition =
readonly graph?: never;
};
interface BunProbeSource {
readonly handle: FileHandle;
readonly fileSize: number;
readonly signal: AbortSignal | undefined;
readonly limits: BunReaderLimits;
}
interface SectionWindow {
readonly carrier: Exclude<BunCarrier, "appended-trailer">;
readonly fileOffset: number;
@@ -133,17 +129,26 @@ export const probeBunStandaloneGraph = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): Promise<BunStandaloneProbe> => {
abortIfNeeded(signal);
const claims = [
...(await machOClaims(handle, fileSize, 0, fileSize, signal)),
...(await peClaims(handle, fileSize, signal)),
...(await elfClaims(handle, fileSize, signal)),
];
const source = { handle, fileSize, signal, limits };
let claims: readonly SectionWindow[];
try {
claims = [
...(await machOClaims(source, 0, fileSize)),
...(await peClaims(source)),
...(await elfClaims(source)),
];
} catch (cause) {
if (!(cause instanceof ArtifactReaderFailure) || cause.reason !== "limit")
throw cause;
return { status: "limited", failure: cause };
}
const real: BunStandaloneProbe[] = [];
for (const claim of claims) {
abortIfNeeded(signal);
const evaluated = await evaluateSection(handle, fileSize, claim, signal);
const evaluated = await evaluateSection(source, claim);
if (evaluated.status === "present") {
if (real.length > 0)
return presentFailure(
@@ -157,7 +162,7 @@ export const probeBunStandaloneGraph = async (
}
const only = real[0];
if (only !== undefined) return only;
return evaluateAppendedTrailer(handle, fileSize, signal);
return evaluateAppendedTrailer(source);
};
/** Read a probed payload once and validate its suffix against the claim. */
@@ -165,14 +170,20 @@ export const loadBunStandaloneGraph = async (
handle: FileHandle,
claim: BunGraphClaim,
signal?: AbortSignal,
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): Promise<BunStandaloneRecognition> => {
if (claim.payloadLength > limits.payload_bytes)
return {
status: "present",
failure: bunPayloadBytesLimit(claim.payloadLength, limits),
};
const suffix = await readExact(
handle,
claim.payloadOffset + claim.payloadLength - BUN_GRAPH_SUFFIX_BYTES,
BUN_GRAPH_SUFFIX_BYTES,
signal,
);
const failure = suffixFailure(suffix, claim.payloadLength);
const failure = suffixFailure(suffix, claim.payloadLength, limits);
if (failure !== undefined) return { status: "present", failure };
const offsets = suffix.subarray(0, BUN_OFFSETS_BYTES);
return {
@@ -184,6 +195,7 @@ export const loadBunStandaloneGraph = async (
claim.payloadOffset,
claim.payloadLength - BUN_GRAPH_SUFFIX_BYTES,
signal,
limits.metadata_read_bytes,
),
modules: readPointer(offsets, 8),
entryPointId: offsets.readUInt32LE(16),
@@ -193,7 +205,6 @@ export const loadBunStandaloneGraph = async (
};
};
const MAX_METADATA_READ_BYTES = 256 * 1024 * 1024;
const METADATA_CACHE_BYTES = 256 * 1024;
const METADATA_CACHE_ENTRIES = 1024;
@@ -206,7 +217,8 @@ class BunGraphRangeReader implements BunGraphData {
private readonly handle: FileHandle,
private readonly start: number,
readonly length: number,
private readonly signal?: AbortSignal,
private readonly signal: AbortSignal | undefined,
private readonly maximumReadBytes: number,
) {}
async read(offset: number, length: number): Promise<Buffer> {
@@ -228,11 +240,12 @@ class BunGraphRangeReader implements BunGraphData {
this.#ranges.set(key, existing);
return Buffer.from(existing);
}
if (length > MAX_METADATA_READ_BYTES - this.#readBytes)
throw artifactFixedLimitFailure(
"Bun metadata read work",
MAX_METADATA_READ_BYTES,
if (length > this.maximumReadBytes - this.#readBytes)
throw artifactReaderLimitFailure(
"artifact_limits.bun.metadata_read_bytes",
this.maximumReadBytes,
"bytes",
this.#readBytes + length,
);
// Charge physical work before allocation or I/O, including uncached large names.
this.#readBytes += length;
@@ -257,29 +270,37 @@ class BunGraphRangeReader implements BunGraphData {
}
}
const bunByteLimit = (
claimed: number | bigint,
scope: "payload" | "metadata",
): ArtifactReaderFailure => {
const allowance = `Bun standalone ${scope} allowance of ${String(MAX_BUN_RETAINED_BYTES)} bytes`;
const message = `The ${scope} claims ${String(claimed)} bytes. ${fixedAllowanceRemediation(allowance, "parser")} This release has no runtime override.`;
return new ArtifactReaderFailure("limit", message, {
boundary: scope === "payload" ? "file-size" : "memory",
userMessage: message,
});
};
export const bunPayloadBytesLimit = (
claimed: number | bigint,
): ArtifactReaderFailure => bunByteLimit(claimed, "payload");
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): ArtifactReaderFailure =>
artifactReaderLimitFailure(
"artifact_limits.bun.payload_bytes",
limits.payload_bytes,
"bytes",
claimed,
"file-size",
);
export const bunMetadataBytesLimit = (
claimed: number | bigint,
): ArtifactReaderFailure => bunByteLimit(claimed, "metadata");
export const bunModuleCountLimit = (count: number): ArtifactReaderFailure =>
new ArtifactReaderFailure(
"limit",
`Bun standalone module graph claims ${String(count)} modules, exceeding MAX_BUN_MODULE_COUNT (${String(MAX_BUN_MODULE_COUNT)} modules). This release has no runtime override. Host memory and NODE_OPTIONS do not raise it.`,
{ boundary: "capacity" },
claimed: number,
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): ArtifactReaderFailure =>
artifactReaderLimitFailure(
"artifact_limits.bun.representation_bytes",
limits.representation_bytes,
"bytes",
claimed,
"memory",
);
export const bunModuleCountLimit = (
count: number,
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): ArtifactReaderFailure =>
artifactReaderLimitFailure(
"artifact_limits.bun.module_count",
limits.module_count,
"count",
count,
);
export const bunFormatFailure = (detail: string): ArtifactReaderFailure =>
@@ -293,11 +314,10 @@ const presentFailure = (
): BunStandaloneProbe => ({ status: "present", failure });
const evaluateSection = async (
handle: FileHandle,
fileSize: number,
source: BunProbeSource,
claim: SectionWindow,
signal?: AbortSignal,
): Promise<BunStandaloneProbe | { readonly status: "placeholder" }> => {
const { handle, signal, limits } = source;
if (claim.truncated)
return presentFailure(
bunFormatFailure("section extends past the end of the file"),
@@ -337,8 +357,8 @@ const evaluateSection = async (
}
}
if (payloadLength === 0n) return { status: "placeholder" };
if (payloadLength > BigInt(MAX_BUN_RETAINED_BYTES))
return presentFailure(bunPayloadBytesLimit(payloadLength));
if (payloadLength > BigInt(limits.payload_bytes))
return presentFailure(bunPayloadBytesLimit(payloadLength, limits));
if (payloadLength < BigInt(BUN_GRAPH_SUFFIX_BYTES))
return presentFailure(bunFormatFailure("trailer is truncated"));
const length = Number(payloadLength);
@@ -351,7 +371,7 @@ const evaluateSection = async (
BUN_GRAPH_SUFFIX_BYTES,
signal,
);
const failure = suffixFailure(suffix, length);
const failure = suffixFailure(suffix, length, limits);
if (failure !== undefined) return presentFailure(failure);
return {
status: "present",
@@ -364,10 +384,9 @@ const evaluateSection = async (
* trailer has to be the exact final bytes; this does not scan backward.
*/
const evaluateAppendedTrailer = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
source: BunProbeSource,
): Promise<BunStandaloneProbe> => {
const { handle, fileSize, signal, limits } = source;
if (fileSize < BUN_GRAPH_SUFFIX_BYTES) return { status: "absent" };
let graphEnd = fileSize;
let trailer = await readExact(
@@ -399,12 +418,9 @@ const evaluateAppendedTrailer = async (
signal,
);
const byteCount = offsets.readBigUInt64LE(0);
if (
byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES) >
BigInt(MAX_BUN_RETAINED_BYTES)
)
if (byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES) > BigInt(limits.payload_bytes))
return presentFailure(
bunPayloadBytesLimit(byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES)),
bunPayloadBytesLimit(byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES), limits),
);
const payloadLength = byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES);
if (payloadLength > BigInt(graphEnd))
@@ -424,14 +440,15 @@ const evaluateAppendedTrailer = async (
const suffixFailure = (
suffix: Buffer,
payloadLength: number,
limits: BunReaderLimits,
): ArtifactReaderFailure | undefined => {
if (suffix.length < BUN_GRAPH_SUFFIX_BYTES)
return bunFormatFailure("trailer is truncated");
if (!suffix.subarray(BUN_OFFSETS_BYTES).equals(BUN_TRAILER))
return bunFormatFailure("trailer does not match the Bun sentinel");
const byteCount = suffix.readBigUInt64LE(0);
if (byteCount > BigInt(MAX_BUN_RETAINED_BYTES))
return bunPayloadBytesLimit(byteCount);
if (byteCount > BigInt(limits.payload_bytes))
return bunPayloadBytesLimit(byteCount, limits);
if (byteCount + BigInt(BUN_GRAPH_SUFFIX_BYTES) !== BigInt(payloadLength))
return bunFormatFailure("byte_count does not match the located payload");
return undefined;
@@ -446,18 +463,17 @@ export const readPointer = (
});
const machOClaims = async (
handle: FileHandle,
fileSize: number,
source: BunProbeSource,
imageOffset: number,
imageLimit: number,
signal?: AbortSignal,
): Promise<readonly SectionWindow[]> => {
const { handle, fileSize, signal, limits } = source;
if (imageOffset < 0 || imageOffset >= fileSize) return [];
const header = await tryRead(handle, imageOffset, 12, fileSize, signal);
if (header === null || header.length < 4) return [];
const facts = readMachoHeaderPrefixFacts(header);
if (facts.identity === null)
return imageOffset === 0 ? fatClaims(handle, fileSize, header, signal) : [];
return imageOffset === 0 ? fatClaims(source, header) : [];
const full = await tryRead(
handle,
imageOffset,
@@ -474,7 +490,13 @@ const machOClaims = async (
headerSize: headerBytes,
commandBytes: sizeofcmds,
} = table;
if (sizeofcmds > MAX_LOAD_COMMAND_BYTES) return [];
if (sizeofcmds > limits.recognition_load_command_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.bun.recognition_load_command_bytes",
limits.recognition_load_command_bytes,
"bytes",
sizeofcmds,
);
const commands = await tryRead(
handle,
imageOffset + headerBytes,
@@ -530,7 +552,7 @@ const segmentClaims = (
claims.push({
carrier: "mach-o-section",
fileOffset: imageOffset + offset,
declaredSize: MAX_BUN_RETAINED_BYTES + 1,
declaredSize: Number.MAX_SAFE_INTEGER,
available: 0,
truncated: false,
});
@@ -544,11 +566,10 @@ const segmentClaims = (
};
const fatClaims = async (
handle: FileHandle,
fileSize: number,
source: BunProbeSource,
prefix: Buffer,
signal?: AbortSignal,
): Promise<readonly SectionWindow[]> => {
const { handle, fileSize, signal } = source;
let parsed = readFatContainerHeader(prefix, fileSize);
if (parsed.state === "needs-data") {
const table = await tryRead(
@@ -566,24 +587,15 @@ const fatClaims = async (
for (const slice of parsed.slices) {
abortIfNeeded(signal);
if (parsed.rangeIssues[slice.index] !== null) continue;
claims.push(
...(await machOClaims(
handle,
fileSize,
slice.offset,
slice.size,
signal,
)),
);
claims.push(...(await machOClaims(source, slice.offset, slice.size)));
}
return claims;
};
const peClaims = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
source: BunProbeSource,
): Promise<readonly SectionWindow[]> => {
const { handle, fileSize, signal } = source;
const dos = await tryRead(handle, 0, 64, fileSize, signal);
if (dos === null || dos.length < 64 || dos[0] !== 0x4d || dos[1] !== 0x5a)
return [];
@@ -619,10 +631,9 @@ const peClaims = async (
};
const elfClaims = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
source: BunProbeSource,
): Promise<readonly SectionWindow[]> => {
const { handle, fileSize, signal, limits } = source;
const header = await tryRead(handle, 0, 64, fileSize, signal);
if (
header === null ||
@@ -646,17 +657,22 @@ const elfClaims = async (
: readU32(header, shoffOffset, littleEndian);
const shnum = readU16(header, shnumOffset, littleEndian);
const shstrndx = readU16(header, shstrndxOffset, littleEndian);
// Extended numbering and unreadable tables stay absent so a native ELF is
// not reclassified just because its section table could not be walked.
// Unsupported extended numbering does not establish a Bun graph.
if (
shoff === undefined ||
shoff === 0 ||
shnum === 0 ||
shnum === 0xffff ||
shstrndx >= shnum ||
shnum * shentsize > MAX_SECTION_TABLE_BYTES
shstrndx >= shnum
)
return [];
if (shnum * shentsize > limits.recognition_table_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.bun.recognition_table_bytes",
limits.recognition_table_bytes,
"bytes",
shnum * shentsize,
);
const table = await tryRead(
handle,
shoff,
@@ -667,20 +683,31 @@ const elfClaims = async (
if (table === null) return [];
const strings = elfSectionBounds(table, shstrndx, wide, littleEndian);
if (strings === null) return [];
const stringTable = await tryRead(
handle,
strings.offset,
Math.min(strings.size, MAX_SECTION_TABLE_BYTES),
fileSize,
signal,
);
if (stringTable === null) return [];
// A section name lookup needs only `.bun` and its terminator. Read the
// referenced range, rather than treating a truncated string-table prefix as
// evidence that later names are absent. Duplicate offsets share one lookup.
const names = new Map<number, boolean>();
const claims: SectionWindow[] = [];
for (let index = 0; index < shnum; index += 1) {
const bounds = elfSectionBounds(table, index, wide, littleEndian);
if (bounds === null) continue;
const nameOffset = readU32(table, index * shentsize, littleEndian);
if (!elfNameIsBun(stringTable, nameOffset)) continue;
let isBun = names.get(nameOffset);
if (isBun === undefined) {
const name =
nameOffset >= strings.size
? null
: await tryRead(
handle,
strings.offset + nameOffset,
Math.min(ELF_BUN_NAME.length + 1, strings.size - nameOffset),
fileSize,
signal,
);
isBun = name !== null && elfNameIsBun(name, 0);
names.set(nameOffset, isBun);
}
if (!isBun) continue;
claims.push(windowFor("elf-section", bounds.offset, bounds.size, fileSize));
}
return claims;
+16 -16
View File
@@ -1,10 +1,12 @@
import {
DEFAULT_BUN_READER_LIMITS,
type BunReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import { setImmediate } from "node:timers/promises";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import {
BUN_RECORD_BYTES_1_3_3,
BUN_RECORD_BYTES_1_3_9,
MAX_BUN_MODULE_COUNT,
MAX_BUN_RETAINED_BYTES,
bunMetadataBytesLimit,
bunFormatFailure,
bunModuleCountLimit,
@@ -90,6 +92,7 @@ export interface BunDecodedGraph {
*/
export const decodeBunModuleGraph = async (
graph: LocatedBunGraph,
limits: BunReaderLimits = DEFAULT_BUN_READER_LIMITS,
): Promise<BunDecodedGraph> => {
const { data } = graph;
const table = graph.modules;
@@ -102,8 +105,8 @@ export const decodeBunModuleGraph = async (
`record table length ${String(table.length)} matches no known revision`,
);
// Count admission precedes table allocation, independently of payload size.
if (candidates.every((size) => table.length / size > MAX_BUN_MODULE_COUNT))
throw bunModuleCountLimit(table.length / Math.max(...candidates));
if (candidates.every((size) => table.length / size > limits.module_count))
throw bunModuleCountLimit(table.length / Math.max(...candidates), limits);
const records = await data.read(table.offset, table.length);
let recordBytes = candidates[0]!;
if (candidates.length > 1) {
@@ -111,12 +114,7 @@ export const decodeBunModuleGraph = async (
for (const recordBytes of candidates)
assessments.push({
recordBytes,
failure: await assessLayout(
data,
records,
recordBytes,
graph.entryPointId,
),
failure: await assessLayout(graph, records, recordBytes, limits),
});
const valid = assessments.filter((item) => item.failure === null);
if (valid.length > 1)
@@ -148,7 +146,8 @@ export const decodeBunModuleGraph = async (
// Bound repeated pointer expansion before decoding strings. JSON escaping and
// UTF-16 strings can expand each source byte; aliased names count per record.
let retained = graph.compileExecArgv.length * 6;
if (retained > MAX_BUN_RETAINED_BYTES) throw bunMetadataBytesLimit(retained);
if (retained > limits.representation_bytes)
throw bunMetadataBytesLimit(retained, limits);
for (let index = 0; index < count; index += 1) {
const record = records.subarray(
index * recordBytes,
@@ -157,8 +156,8 @@ export const decodeBunModuleGraph = async (
retained += 512 + readPointer(record, 0).length * 6;
if (recordBytes === BUN_RECORD_BYTES_1_3_9)
retained += readPointer(record, 40).length * 6;
if (retained > MAX_BUN_RETAINED_BYTES)
throw bunMetadataBytesLimit(retained);
if (retained > limits.representation_bytes)
throw bunMetadataBytesLimit(retained, limits);
if (index % 256 === 0) await setImmediate();
if (candidates.length === 1) {
const failure = await recordPointerFailure(
@@ -191,13 +190,14 @@ const recordSizes = (length: number): readonly number[] => {
};
const assessLayout = async (
data: BunGraphData,
graph: LocatedBunGraph,
records: Buffer,
recordBytes: number,
entryPointId: number,
limits: BunReaderLimits,
): Promise<ArtifactReaderFailure | null> => {
const { data, entryPointId } = graph;
const count = records.length / recordBytes;
if (count > MAX_BUN_MODULE_COUNT) return bunModuleCountLimit(count);
if (count > limits.module_count) return bunModuleCountLimit(count, limits);
for (let index = 0; index < count; index += 1) {
const record = records.subarray(
index * recordBytes,
+12 -4
View File
@@ -17,7 +17,7 @@ export const locateBunGraphFixture = async (
fileSize: number,
): Promise<BunStandaloneRecognition> => {
const probed = await probeBunStandaloneGraph(handle, fileSize);
if (probed.status === "absent" || probed.claim === undefined) return probed;
if (probed.status !== "present" || probed.claim === undefined) return probed;
return loadBunStandaloneGraph(handle, probed.claim);
};
@@ -216,8 +216,10 @@ export const elfAppendedBunPayload = (payload: Buffer): Buffer =>
Buffer.concat([elfPrefix(), payload]);
/** ELF64 whose `.bun` section holds the length-prefixed graph. */
export const elfBunSection = (section: Buffer): Buffer => {
const shstr = Buffer.from("\0.shstrtab\0.bun\0");
export const elfBunSection = (section: Buffer, nameOffset = 11): Buffer => {
const shstr = Buffer.alloc(nameOffset + 5);
shstr.write("\0.shstrtab\0");
shstr.write(".bun\0", nameOffset);
const shstrOffset = 64;
const sectionOffset = shstrOffset + shstr.length;
const tableOffset = sectionOffset + section.length;
@@ -231,7 +233,13 @@ export const elfBunSection = (section: Buffer): Buffer => {
shstr.copy(bytes, shstrOffset);
section.copy(bytes, sectionOffset);
writeElfSection(bytes, tableOffset + 64, 1, shstrOffset, shstr.length);
writeElfSection(bytes, tableOffset + 128, 11, sectionOffset, section.length);
writeElfSection(
bytes,
tableOffset + 128,
nameOffset,
sectionOffset,
section.length,
);
return bytes;
};
@@ -1,12 +1,19 @@
import { open, writeFile, type FileHandle } from "node:fs/promises";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { buffer } from "node:stream/consumers";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import * as artifactPaths from "../ArtifactPaths.js";
import { BunStandaloneArtifactReader } from "../BunStandaloneArtifactReader.js";
import { inventoryArtifact } from "../inventory/ArtifactInventory.js";
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import { resolveArtifactReaderLimits } from "../../domain/artifactReaderLimits.js";
import {
bunModuleGraphPayload,
bunPrefixedPayload,
elfBunSection,
elfPrefix,
elfAppendedBunPayload,
} from "./BunStandalone.fixture.js";
@@ -58,6 +65,65 @@ it("keeps duplicate and unsafe names distinct while extracting exact bytes", asy
});
});
it("keeps exhausted Bun recognition distinct from a native ELF inventory and recovers the same carrier", async () => {
const path = join(await createTestTempDirectory("rea-bun-inventory-"), "app");
const bytes = elfBunSection(
bunPrefixedPayload(
bunModuleGraphPayload({
modules: [{ name: "main.js", contents: Buffer.from("retained") }],
}),
),
);
await writeFile(path, bytes);
const rootSha256 = createHash("sha256").update(bytes).digest("hex");
const scan = async (recognitionTableBytes: number) => {
const scope = new ArtifactResourceScope();
try {
return await inventoryArtifact(path, {
resourceScope: scope,
readerLimits: resolveArtifactReaderLimits({
bun: { recognition_table_bytes: recognitionTableBytes },
}),
});
} finally {
await scope.close();
}
};
const limited = await scan(191);
expect(limited.manifest).toMatchObject({
root_format: "elf",
root_sha256: rootSha256,
occurrence_count: 1,
});
expect(limited.limitations).toContainEqual(
expect.stringContaining("Bun graph recognition remains unknown:"),
);
expect(limited.limitations).toContainEqual(
expect.stringContaining(
"artifact_limits.bun.recognition_table_bytes (191 bytes); requires 192 bytes",
),
);
const recovered = await scan(192);
expect(recovered.manifest).toMatchObject({
root_format: "bun-standalone",
root_sha256: rootSha256,
occurrence_count: 2,
});
expect(recovered.occurrences.map((entry) => entry.logical_path)).toContain(
"main.js",
);
expect((await scan(191)).limitations).toEqual(limited.limitations);
const nativeElf = elfPrefix();
await writeFile(path, nativeElf);
const ordinary = await scan(1);
expect(ordinary.manifest).toMatchObject({
root_format: "elf",
root_sha256: createHash("sha256").update(nativeElf).digest("hex"),
occurrence_count: 1,
});
});
async function collect<T>(values: AsyncIterable<T>): Promise<T[]> {
const result: T[] = [];
for await (const value of values) result.push(value);
@@ -34,6 +34,38 @@ import {
machOWithSegment,
} from "./DenoStandalone.fixture.js";
import { MAX_DENO_PATH_COMPONENT_BYTES } from "./DenoVirtualFs.js";
import { resolveArtifactReaderLimits } from "../../domain/artifactReaderLimits.js";
it("keeps a confirmed Deno inventory when another pack recognizer is limited", async () => {
const original = denoElf();
// Add a valid empty section table without changing the existing PT_NOTE.
const bytes = Buffer.concat([original, Buffer.alloc(3 * 64)]);
bytes.writeBigUInt64LE(BigInt(original.length), 40);
bytes.writeUInt16LE(64, 58);
bytes.writeUInt16LE(3, 60);
bytes.writeUInt16LE(0, 62);
const path = await writeCarrier("deno-with-sections", bytes);
const inventory = await scanCanonicalArtifactInventory(path, {
readerLimits: resolveArtifactReaderLimits({
bun: { recognition_table_bytes: 191 },
}),
});
expect(inventory.manifest).toMatchObject({
root_format: "deno-standalone",
root_sha256: createHash("sha256").update(bytes).digest("hex"),
});
expect(inventory.limitations).toContainEqual(
expect.stringContaining("Bun graph recognition remains unknown:"),
);
expect(inventory.limitations).toContainEqual(
expect.stringContaining(
"artifact_limits.bun.recognition_table_bytes (191 bytes)",
),
);
expect(inventory.occurrences[0]?.limitations).toContainEqual(
expect.stringContaining("Bun graph recognition remains unknown:"),
);
});
const secret = "super-secret-value";
+52 -41
View File
@@ -1,4 +1,9 @@
import type { FileHandle } from "node:fs/promises";
import {
DEFAULT_DOTNET_BUNDLE_READER_LIMITS,
type DotnetBundleReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
import { readArtifactFileChunks } from "../readArtifactFileChunks.js";
import {
@@ -17,16 +22,6 @@ export const DOTNET_BUNDLE_SIGNATURE = Buffer.from(
"hex",
);
// These are resource budgets, not format limits. Metadata stays bounded even
// when malicious manifests alias the same bytes or declare many empty files.
const MAX_METADATA_BYTES = 16 * 1024 * 1024;
const MAX_ENTRIES = 100_000;
const MAX_PATH_BYTES = 64 * 1024;
const MAX_PATH_PARTS = 256;
const MAX_PATH_NODES = 250_000;
const MAX_BUNDLE_ENTRY_BYTES = 512 * 1024 * 1024;
const MAX_EXPANDED_BYTES = 2 * 1024 * 1024 * 1024;
export interface DotnetBundleFile {
readonly path: string;
readonly offset: number;
@@ -85,8 +80,9 @@ export const readDotnetBundle = async (
size: number,
headerOffset: number,
signal?: AbortSignal,
limits: DotnetBundleReaderLimits = DEFAULT_DOTNET_BUNDLE_READER_LIMITS,
): Promise<DotnetBundle> => {
const cursor = new BundleCursor(handle, size, headerOffset, signal);
const cursor = new BundleCursor(handle, size, headerOffset, limits, signal);
const majorVersion = await cursor.u32();
const minorVersion = await cursor.u32();
if (![1, 2, 6].includes(majorVersion) || minorVersion !== 0)
@@ -96,11 +92,12 @@ export const readDotnetBundle = async (
);
const count = await cursor.i32();
if (count < 0) fail(".NET bundle entry count is negative");
if (count > MAX_ENTRIES)
throw artifactFixedLimitFailure(
".NET bundle entry count",
MAX_ENTRIES,
"entries",
if (count > limits.entry_count)
throw artifactReaderLimitFailure(
"artifact_limits.dotnet_bundle.entry_count",
limits.entry_count,
"count",
count,
);
await cursor.string(); // Bundle ID is not an authentication or content digest.
const configuration: { offset: number; size: number; type: number }[] = [];
@@ -124,10 +121,17 @@ export const readDotnetBundle = async (
}
const files: DotnetBundleFile[] = [];
const paths = new ArtifactPathRegistry({
maxParts: MAX_PATH_PARTS,
maxNodes: MAX_PATH_NODES,
maxParts: limits.path_parts,
maxNodes: limits.path_nodes,
limitFailure: (field, maximum, required) =>
artifactReaderLimitFailure(
`artifact_limits.dotnet_bundle.${field}`,
maximum,
"count",
required,
),
});
let expandedBytes = 0;
let expandedBytes = 0n;
for (let index = 0; index < count; index++) {
abortIfNeeded(signal);
const offset = await cursor.nonnegativeI64();
@@ -138,21 +142,20 @@ export const readDotnetBundle = async (
const path = await cursor.string();
const normalized = normalizeArtifactPath(path);
paths.add(normalized, "file");
if (
fileSize > MAX_BUNDLE_ENTRY_BYTES ||
compressedSize > MAX_BUNDLE_ENTRY_BYTES
)
throw artifactFixedLimitFailure(
".NET bundle member stored or expanded size",
MAX_BUNDLE_ENTRY_BYTES,
if (fileSize > limits.member_bytes || compressedSize > limits.member_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.dotnet_bundle.member_bytes",
limits.member_bytes,
"bytes",
Math.max(fileSize, compressedSize),
);
expandedBytes += fileSize;
if (expandedBytes > MAX_EXPANDED_BYTES)
throw artifactFixedLimitFailure(
".NET bundle aggregate expanded size",
MAX_EXPANDED_BYTES,
expandedBytes += BigInt(fileSize);
if (expandedBytes > BigInt(limits.expanded_bytes))
throw artifactReaderLimitFailure(
"artifact_limits.dotnet_bundle.expanded_bytes",
limits.expanded_bytes,
"bytes",
expandedBytes,
);
const storedSize = compressedSize || fileSize;
if (offset > headerOffset || storedSize > headerOffset - offset)
@@ -194,6 +197,7 @@ class BundleCursor {
private readonly handle: FileHandle,
private readonly size: number,
private readonly start: number,
private readonly limits: DotnetBundleReaderLimits,
private readonly signal?: AbortSignal,
) {
this.#position = start;
@@ -201,11 +205,13 @@ class BundleCursor {
async take(length: number): Promise<Buffer> {
abortIfNeeded(this.signal);
if (this.#position - this.start + length > MAX_METADATA_BYTES)
throw artifactFixedLimitFailure(
".NET bundle metadata",
MAX_METADATA_BYTES,
const consumed = this.#position - this.start;
if (consumed + length > this.limits.metadata_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.dotnet_bundle.metadata_bytes",
this.limits.metadata_bytes,
"bytes",
consumed + length,
);
if (length > this.size - this.#position)
fail("Truncated .NET bundle manifest");
@@ -213,7 +219,11 @@ class BundleCursor {
if (cached < 0 || cached + length > this.#cache.length) {
this.#cacheOffset = this.#position;
this.#cache = Buffer.alloc(
Math.min(Math.max(length, 64 * 1024), this.size - this.#position),
Math.min(
Math.max(length, 64 * 1024),
this.size - this.#position,
this.limits.metadata_bytes - consumed,
),
);
let read = 0;
while (read < this.#cache.length) {
@@ -264,11 +274,12 @@ class BundleCursor {
if (shift === 28 && byte > 15) fail("Invalid .NET bundle string length");
length += (byte & 127) * 2 ** shift;
if ((byte & 128) !== 0) continue;
if (length > MAX_PATH_BYTES)
throw artifactFixedLimitFailure(
".NET bundle decoded string",
MAX_PATH_BYTES,
"UTF-8 bytes",
if (length > this.limits.string_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.dotnet_bundle.string_bytes",
this.limits.string_bytes,
"bytes",
length,
);
// Source failures are not encoding failures. Acquire bytes outside the
// decoder boundary so I/O and cancellation retain their original cause.
+41 -7
View File
@@ -1,4 +1,5 @@
import { selectArtifactExtractionOccurrences } from "./ArtifactExtractionSelection.js";
import type { ArtifactReaderLimits } from "../../domain/artifactReaderLimits.js";
import { assertAdmittedRootDigest } from "../inventory/classify.js";
import { assertArtifactFileSelectedSource } from "../ArtifactFileSource.js";
import {
@@ -64,6 +65,7 @@ import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrity
/** Local extraction input with the output root chosen by the adapter. */
export interface ArtifactExtractionInput {
readonly readerLimits?: ArtifactReaderLimits;
readonly asarHeaderAdmission?: AsarHeaderAdmission;
/** Caller inputs when the target was explicitly selected as an ESP flash image. */
readonly espFlashSelection?: EspFlashSelection | undefined;
@@ -84,7 +86,12 @@ export const extractArtifact = async (
input: ArtifactExtractionInput,
signal?: AbortSignal,
): Promise<ArtifactExtractionResult> =>
input.resourceScope.run(() => extractArtifactInScope(input, signal));
input.resourceScope.run(async () => {
const result = await extractArtifactInScope(input, signal);
return input.readerLimits === undefined
? result
: { ...result, effective_limits: input.readerLimits };
});
const extractArtifactInScope = async (
input: ArtifactExtractionInput,
@@ -110,6 +117,7 @@ const extractArtifactInScope = async (
resolveAsarHeaderAdmission(input.environment),
espFlashSelection: input.espFlashSelection,
integrity: input.integrityPolicy,
readerLimits: input.readerLimits,
},
probeFacts,
);
@@ -627,6 +635,27 @@ const createExtractionResult = (
* The target kind, not the host, is unsupported by extraction. The error keeps
* the caller's spelling of the path; I/O uses the canonical path.
*/
const hasExecutablePack = async (
probes: ArtifactEmbeddedFormatProbes,
): Promise<boolean> => {
if (
(await probes.nodeSea()) !== undefined ||
(await probes.pyInstaller()) !== undefined
)
return true;
const bun = await probes.bunStandalone();
if (
bun.status === "present" ||
(await probes.denoStandalone()).status === "present" ||
(await probes.dotnetBundle()) !== undefined
)
return true;
if (bun.status === "limited") throw bun.failure;
const seaFailure = probes.completedFacts().nodeSeaRecognitionFailure;
if (seaFailure !== undefined) throw seaFailure;
return false;
};
const requireExtractableFormat = async (
path: string,
input: Pick<
@@ -636,6 +665,7 @@ const requireExtractableFormat = async (
| "inputFormat"
| "resourceScope"
| "expectedRootSha256"
| "readerLimits"
>,
signal?: AbortSignal,
): Promise<ArtifactEmbeddedFormatProbeFacts | undefined> => {
@@ -650,15 +680,17 @@ const requireExtractableFormat = async (
input.resourceScope,
signal,
async (source) => {
const probes = new ArtifactEmbeddedFormatProbes(path, source, signal);
const probes = new ArtifactEmbeddedFormatProbes(
path,
source,
signal,
undefined,
input.readerLimits,
);
const pack =
(await probes.godotPck()) !== undefined ||
((format === "elf" || format === "pe" || format === "mach-o") &&
((await probes.nodeSea()) !== undefined ||
(await probes.pyInstaller()) !== undefined ||
(await probes.bunStandalone()).status === "present" ||
(await probes.denoStandalone()).status === "present" ||
(await probes.dotnetBundle()) !== undefined));
(await hasExecutablePack(probes)));
if (pack && input.expectedRootSha256 !== undefined) {
const digest = await hashStableRootArtifactHandle(
path,
@@ -697,6 +729,7 @@ const createReader = async (
| "asarHeaderAdmission"
| "resourceScope"
| "espFlashSelection"
| "readerLimits"
>,
manifest: ArtifactGraphManifest,
transferredSource?: ActiveArtifactInventorySource,
@@ -757,6 +790,7 @@ const createReader = async (
),
admission: transferredSource?.admission,
espFlashSelection: input.espFlashSelection,
readerLimits: input.readerLimits,
asarHeaderAdmission:
containerFormat === "asar"
? (input.asarHeaderAdmission ??
+6 -1
View File
@@ -23,7 +23,12 @@ export const inventoryArtifact = async (
const snapshot = await options.resourceScope.run(() =>
scanArtifactInventoryInScope(inputPath, options),
);
return artifactInventoryResultSchema.parse(snapshot);
return artifactInventoryResultSchema.parse({
...snapshot,
...(options.readerLimits === undefined
? {}
: { effective_limits: options.readerLimits }),
});
};
export const scanArtifactInventoryInScope = async (
+33 -2
View File
@@ -4,6 +4,10 @@ import {
parseInesLayout,
} from "../../domain/inesContainer.js";
import type { GodotPckAdmission } from "../GodotPckLocation.js";
import {
resolveArtifactReaderLimits,
type ArtifactReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js";
import type {
ArtifactReaderAdmission,
@@ -71,6 +75,7 @@ interface RootClassification {
type RootInventoryClassification = RootClassification & {
readonly rootSource: RootInventorySource | undefined;
readonly readerAdmission: ArtifactReaderAdmission | undefined;
readonly recognitionLimitations?: readonly string[];
};
/** The admitted descriptor and the single owner that can confirm its close. */
@@ -91,6 +96,7 @@ export const classifyAndHashRootForInventory = async (
| "selectedPath"
| "expectedRootSha256"
| "espFlashSelection"
| "readerLimits"
>,
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
): Promise<RootInventoryClassification> => {
@@ -143,6 +149,7 @@ export const classifyAndHashRootForInventory = async (
source,
options.signal,
probeFacts,
options.readerLimits,
);
const admittedDigest =
options.expectedRootSha256 === undefined
@@ -167,6 +174,7 @@ export const classifyAndHashRootForInventory = async (
options.asarHeaderAdmission,
options.selectedPath,
options.espFlashSelection,
options.readerLimits,
);
const format =
typeof classified === "string" ? classified : classified.kind;
@@ -180,11 +188,26 @@ export const classifyAndHashRootForInventory = async (
));
const retainSource =
artifactFormatDescriptor(format)?.retainClassifiedSource === true;
const bunRecognition = probes.completedFacts().bunStandalone;
const seaRecognition = probes.completedFacts().nodeSeaRecognitionFailure;
const recognitionLimitations = [
...(bunRecognition?.status === "limited"
? [
`Bun graph recognition remains unknown: ${bunRecognition.failure.message}`,
]
: []),
...(seaRecognition === undefined
? []
: [`Node SEA recognition remains unknown: ${seaRecognition.message}`]),
];
outcome = {
kind: "completed",
value: {
format,
digest,
...(recognitionLimitations.length === 0
? {}
: { recognitionLimitations }),
rootSource: retainSource ? source : undefined,
readerAdmission:
typeof classified !== "string"
@@ -220,6 +243,7 @@ const classifyRootFormat = async (
asarHeaderAdmission?: AsarHeaderAdmission,
selectedPath?: string,
espFlashSelection?: EspFlashSelection,
readerLimits: ArtifactReaderLimits = resolveArtifactReaderLimits(),
): Promise<ArtifactOccurrence["artifact_format"] | ArtifactFormatAdmission> => {
const { handle } = source;
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
@@ -354,10 +378,17 @@ const classifyRootFormat = async (
abortIfNeeded(signal);
if (sea !== undefined) {
const decoded = await decodeNodeSeaBlob(
await readNodeSeaBlob(handle, sea, archiveBytes, signal),
await readNodeSeaBlob(source, sea, signal, readerLimits.node_sea),
signal,
readerLimits.node_sea,
);
return { kind: "node-sea", source, location: sea, decoded };
return {
kind: "node-sea",
source,
location: sea,
decoded,
limits: readerLimits.node_sea,
};
}
const bun = await embeddedProbes.bunStandalone();
if (bun.status === "present")
+3
View File
@@ -1,4 +1,5 @@
import type { ArtifactReaderAdmission } from "../ArtifactReaderAdmission.js";
import type { ArtifactReaderLimits } from "../../domain/artifactReaderLimits.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
import type {
ArtifactReader,
@@ -21,6 +22,7 @@ export const createReader = (
readerAdmission?: ArtifactReaderAdmission,
asarHeaderAdmission?: AsarHeaderAdmission,
selectedPath?: string,
readerLimits?: ArtifactReaderLimits,
): ArtifactReader | undefined => {
if (
artifactFormatDescriptor(format)?.requiresDarwin === true &&
@@ -33,6 +35,7 @@ export const createReader = (
admission: readerAdmission,
asarHeaderAdmission,
selectedPath,
readerLimits,
});
};
+10 -1
View File
@@ -125,6 +125,7 @@ const scanCanonical = async (
digest: rootDigest,
rootSource,
readerAdmission,
recognitionLimitations,
} = await classifyAndHashRootForInventory(
path,
metadata.isDirectory(),
@@ -142,6 +143,7 @@ const scanCanonical = async (
readerAdmission,
options.asarHeaderAdmission,
selectedPath,
options.readerLimits,
);
} catch (cause: unknown) {
readerCreationFailure = { cause };
@@ -187,6 +189,7 @@ const scanCanonical = async (
unknownParentOccurrences,
traversal,
rootContradiction,
recognitionLimitations,
}),
};
} catch (cause: unknown) {
@@ -406,6 +409,7 @@ const cleanupInventoryOwners = async ({
};
interface SnapshotBuildInput {
readonly recognitionLimitations?: readonly string[] | undefined;
readonly resourceScope: ArtifactInventoryOptions["resourceScope"];
readonly path: string;
readonly metadata: Stats;
@@ -443,6 +447,7 @@ const buildInventorySnapshot = async (
path,
formatMetadata: reader?.formatMetadata?.(),
});
rootOccurrence.limitations.push(...(input.recognitionLimitations ?? []));
for (const occurrence of input.occurrences)
if (
occurrence.parent_occurrence_id === null &&
@@ -537,7 +542,11 @@ const buildInventorySnapshot = async (
integrity_contradictions: integrityContradictions,
coverage,
limitations: buildLimitations(
[...traversal.limitations, ...edgeResult.limitations],
[
...traversal.limitations,
...edgeResult.limitations,
...(input.recognitionLimitations ?? []),
],
integrityContradictions,
input.caseCollisionLimitation,
),
+2
View File
@@ -2,9 +2,11 @@ import type { EspFlashSelection } from "../../domain/firmware/espFlashLocation.j
import type { ArtifactIntegrityPolicyName } from "../../domain/artifactIntegrityPolicy.js";
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
import type { ArtifactResourceScope } from "../ArtifactResourceScope.js";
import type { ArtifactReaderLimits } from "../../domain/artifactReaderLimits.js";
/** Options shared by artifact inventory scans. */
export interface ArtifactInventoryOptions {
readonly readerLimits?: ArtifactReaderLimits | undefined;
readonly resourceScope: ArtifactResourceScope;
/** Identity of a previously admitted file root; directory roots have no file digest. */
readonly expectedRootSha256?: string | undefined;
@@ -1,3 +1,4 @@
import { resolveJavaScriptSemanticPrecision } from "../../domain/javascript/javascriptSemanticPrecision.js";
import type {
JavaScriptSourceAnalysisLimits,
JavaScriptSourceAnalysisPort,
@@ -128,8 +129,20 @@ const registerJavaScriptSourceAnalysisClose = (
const sameLimits = (
left: JavaScriptSourceAnalysisLimits,
right: JavaScriptSourceAnalysisLimits,
): boolean =>
left.heapMb === right.heapMb && left.timeoutMs === right.timeoutMs;
): boolean => {
if (left.heapMb !== right.heapMb || left.timeoutMs !== right.timeoutMs)
return false;
const leftPrecision = resolveJavaScriptSemanticPrecision(
left.semanticPrecision,
);
const rightPrecision = resolveJavaScriptSemanticPrecision(
right.semanticPrecision,
);
return Object.entries(leftPrecision).every(
([key, value]) =>
rightPrecision[key as keyof typeof rightPrecision] === value,
);
};
/**
* Park a healthy worker on its scope. The next checkout with the same limits
@@ -1,6 +1,7 @@
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
import type { DecodedNodeSeaBlob } from "./NodeSeaBlob.js";
import type { NodeSeaLocation } from "./NodeSeaLocation.js";
import type { NodeSeaReaderLimits } from "../../domain/artifactReaderLimits.js";
/** Validated blob facts borrowed with the exact descriptor that produced them. */
export interface NodeSeaAdmission {
@@ -8,4 +9,6 @@ export interface NodeSeaAdmission {
readonly source: StableRegularFileDescriptor;
readonly location: NodeSeaLocation;
readonly decoded: DecodedNodeSeaBlob;
/** Accepted producing policy; older admissions require fresh admission. */
readonly limits?: NodeSeaReaderLimits;
}
+68 -65
View File
@@ -2,7 +2,7 @@ import { setImmediate } from "node:timers/promises";
import { expect, it } from "vitest";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import { DEFAULT_NODE_SEA_READER_LIMITS } from "../../domain/artifactReaderLimits.js";
import {
NODE_SEA_MAGIC,
decodeNodeSeaBlob,
@@ -134,80 +134,83 @@ it.each([
},
);
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 assets = Array.from({ length: 100_001 }, (_, index) => ({
name: `asset-${String(index)}`,
body: "",
}));
const failure = await limitOf(buildNodeSeaBlob({ assets }));
expect(failure).toMatchObject({
reason: "limit",
message: expect.stringMatching(
/asset count.*100000.*no runtime override/iu,
),
});
});
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: "" },
],
it("rejects impossible asset rows before applying resource admission", async () => {
const prefix = buildNodeSeaBlob({ assets: [{ name: "a", body: "" }] });
const count = Buffer.alloc(8);
count.writeBigUInt64LE(2n);
const bytes = Buffer.concat([prefix.subarray(0, -25), count]);
await expect(
decodeNodeSeaBlob(bytes, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
asset_count: 1,
}),
);
expect(failure).toMatchObject({
reason: "limit",
message: expect.stringMatching(
/asset names.*8388608.*no runtime override/iu,
),
).rejects.toMatchObject({
reason: "format",
message: expect.stringMatching(/Node SEA.*truncated/iu),
readerLimit: undefined,
});
});
it.each([
{
label: "count",
accepted: () => Array.from({ length: 4096 }, () => "x"),
args: () => Array.from({ length: 4097 }, () => "x"),
boundary: "capacity",
warning: /exec argv count.*4096.*no runtime override/iu,
},
{
label: "text",
accepted: () => ["x".repeat(1024 * 1024)],
args: () => ["x".repeat(1024 * 1024 + 1)],
boundary: "memory",
warning: /exec argv text.*1048576.*no runtime override/iu,
},
{ field: "asset_count" as const, limit: 1, raised: 2 },
{ field: "asset_name_bytes" as const, limit: 2, raised: 3 },
{ field: "argument_count" as const, limit: 1, raised: 2 },
{ field: "argument_bytes" as const, limit: 2, raised: 3 },
])(
"admits exec argument $label at its bound and refuses more",
async ({ accepted, args, boundary, warning }) => {
const atLimit = accepted();
expect(
(await decodeNodeSeaBlob(buildNodeSeaBlob({ execArgv: atLimit })))
.execArgv,
).toEqual(atLimit);
const failure = await limitOf(buildNodeSeaBlob({ execArgv: args() }));
expect(failure).toMatchObject({
reason: "limit",
boundary,
message: expect.stringMatching(warning),
"recovers the same blob after raising $field",
async ({ field, limit, raised }) => {
const bytes = buildNodeSeaBlob({
assets: [
{ name: "a", body: "one" },
{ name: "bb", body: "two" },
],
execArgv: ["x", "yz"],
});
await expect(
decodeNodeSeaBlob(bytes, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
[field]: limit,
}),
).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: `artifact_limits.node_sea.${field}`,
effective_value: limit,
},
});
const recovered = await decodeNodeSeaBlob(bytes, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
[field]: raised,
});
expect(recovered.assets.map((asset) => asset.name)).toEqual(["a", "bb"]);
expect(recovered.execArgv).toEqual(["x", "yz"]);
},
);
it("applies blob byte admission even when called without a carrier reader", async () => {
const bytes = buildNodeSeaBlob({ main: "hello" });
await expect(
decodeNodeSeaBlob(bytes, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
blob_bytes: bytes.length - 1,
}),
).rejects.toMatchObject({
reason: "limit",
readerLimit: {
parameter: "artifact_limits.node_sea.blob_bytes",
required_value: bytes.length,
},
});
expect(
(
await decodeNodeSeaBlob(bytes, undefined, {
...DEFAULT_NODE_SEA_READER_LIMITS,
blob_bytes: bytes.length,
})
).mainScript?.length,
).toBe(5);
});
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");
+54 -33
View File
@@ -2,13 +2,15 @@ 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";
/** 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;
@@ -21,15 +23,6 @@ const KNOWN_FLAGS =
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.";
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";
@@ -54,11 +47,6 @@ const NODE_SEA_FUSE_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 NODE_SEA_ASSET_COUNT = `Node SEA asset count exceeds the ${String(MAX_ASSET_COUNT)}-asset bound. ${NO_OVERRIDE}`;
const NODE_SEA_ASSET_NAME_BYTES = `Node SEA asset names exceed the ${String(MAX_ASSET_NAME_BYTES)}-byte name budget. ${NO_OVERRIDE}`;
const NODE_SEA_EXEC_ARGV_COUNT = `Node SEA exec argv count exceeds the ${String(MAX_EXEC_ARGV)}-argument bound. ${NO_OVERRIDE}`;
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`;
const FUSE_PREFIX = "NODE_SEA_FUSE_";
@@ -98,12 +86,6 @@ export interface NodeSeaFuseObservation {
readonly fuseContradiction: string | null;
}
const limitFailure = (
message: string,
boundary: "capacity" | "memory",
): ArtifactReaderFailure =>
new ArtifactReaderFailure("limit", message, { boundary });
const formatFailure = (
message: string,
cause?: unknown,
@@ -123,9 +105,18 @@ const formatFailure = (
export const decodeNodeSeaBlob = async (
bytes: Buffer,
signal?: AbortSignal,
limits: NodeSeaReaderLimits = DEFAULT_NODE_SEA_READER_LIMITS,
): Promise<DecodedNodeSeaBlob> => {
assertArtifactReadActive(signal);
const cursor = new BlobCursor(bytes);
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);
@@ -212,7 +203,10 @@ const extensionName = (value: number): NodeSeaExecArgvExtension => {
class BlobCursor {
#offset = 0;
constructor(private readonly bytes: Buffer) {}
constructor(
private readonly bytes: Buffer,
private readonly limits: NodeSeaReaderLimits,
) {}
get remaining(): number {
return this.bytes.length - this.#offset;
@@ -241,6 +235,12 @@ class BlobCursor {
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);
@@ -255,14 +255,20 @@ class BlobCursor {
}
async assets(signal?: AbortSignal): Promise<NodeSeaAsset[]> {
const count = this.#count(MAX_ASSET_COUNT, 16, NODE_SEA_ASSET_COUNT);
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 > MAX_ASSET_NAME_BYTES)
throw limitFailure(NODE_SEA_ASSET_NAME_BYTES, "memory");
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) {
@@ -279,13 +285,19 @@ class BlobCursor {
}
async execArgv(signal?: AbortSignal): Promise<string[]> {
const count = this.#count(MAX_EXEC_ARGV, 8, NODE_SEA_EXEC_ARGV_COUNT);
const count = this.#count("argument_count", 8);
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");
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) {
@@ -324,13 +336,22 @@ class BlobCursor {
return Number(value);
}
#count(limit: number, minimumRowBytes: number, message: string): number {
#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 > limit) throw limitFailure(message, "capacity");
if (count > this.limits[field])
throw artifactReaderLimitFailure(
`artifact_limits.node_sea.${field}`,
this.limits[field],
"count",
count,
);
return count;
}
+69 -25
View File
@@ -4,6 +4,7 @@ import { readElfSectionHeader } from "../../domain/elfSectionHeader.js";
import { readElfProgramHeader } from "../../domain/elfProgramHeader.js";
import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js";
import { createHash } from "node:crypto";
import { constants as bufferConstants } from "node:buffer";
import { parsePeResources } from "../../native/pe/PeResourceParser.js";
import {
hasPeResourceName,
@@ -14,9 +15,15 @@ import {
MAX_INSPECT_PE_DIRECTORY_ENTRIES,
} from "../../domain/native/peResources.js";
import type { FileHandle } from "node:fs/promises";
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
import { abortIfNeeded } from "../ArtifactHash.js";
import { ArtifactReaderFailure } from "../ArtifactReader.js";
import {
DEFAULT_NODE_SEA_READER_LIMITS,
type NodeSeaReaderLimits,
} from "../../domain/artifactReaderLimits.js";
import { artifactReaderLimitFailure } from "../ArtifactReaderLimits.js";
import {
readFatContainerHeader,
readFatHeaderPrefix,
@@ -30,14 +37,12 @@ import {
readElfNoteHeader,
} from "../../domain/native/elfNoteLayout.js";
import {
NODE_SEA_BLOB_TOO_LARGE,
NODE_SEA_DUPLICATE_BLOB,
NODE_SEA_ELF_DESCRIPTOR_TRUNCATED,
NODE_SEA_ELF_NOTES_REPEATED,
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,
@@ -50,9 +55,6 @@ 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;
@@ -73,6 +75,7 @@ interface Carrier {
readonly handle: FileHandle;
readonly fileSize: number;
readonly signal: AbortSignal | undefined;
readonly limits?: NodeSeaReaderLimits;
}
const formatFailure = (message: string): ArtifactReaderFailure =>
@@ -81,6 +84,22 @@ const formatFailure = (message: string): ArtifactReaderFailure =>
const limitFailure = (message: string): ArtifactReaderFailure =>
new ArtifactReaderFailure("limit", message, { boundary: "memory" });
const admitBlobBytes = (carrier: Carrier, required: number): void => {
const limits = carrier.limits ?? DEFAULT_NODE_SEA_READER_LIMITS;
if (required > limits.blob_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.node_sea.blob_bytes",
limits.blob_bytes,
"bytes",
required,
"memory",
);
if (required > bufferConstants.MAX_LENGTH)
throw limitFailure(
`Node SEA blob requires ${String(required)} bytes, exceeding this runtime's ${String(bufferConstants.MAX_LENGTH)}-byte Buffer representation limit; increasing artifact_limits.node_sea.blob_bytes cannot raise that runtime limit`,
);
};
/**
* 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
@@ -93,9 +112,10 @@ export const locateNodeSea = async (
handle: FileHandle,
fileSize: number,
signal?: AbortSignal,
limits: NodeSeaReaderLimits = DEFAULT_NODE_SEA_READER_LIMITS,
): Promise<NodeSeaLocation | undefined> => {
if (!Number.isSafeInteger(fileSize) || fileSize < 4) return undefined;
const carrier: Carrier = { handle, fileSize, signal };
const carrier: Carrier = { handle, fileSize, signal, limits };
const prefix = await readExact(carrier, 0, Math.min(fileSize, 64));
if (prefix === undefined || prefix.length < 4) return undefined;
const thin = readMachoHeaderPrefixFacts(prefix).format;
@@ -120,13 +140,16 @@ export const locateNodeSea = async (
/** Read a located blob, refusing declarations above the in-memory cap. */
export const readNodeSeaBlob = async (
handle: FileHandle,
source: StableRegularFileDescriptor,
location: NodeSeaLocation,
fileSize: number,
signal?: AbortSignal,
limits: NodeSeaReaderLimits = DEFAULT_NODE_SEA_READER_LIMITS,
): Promise<Buffer> => {
if (location.blobLength > NODE_SEA_MAX_BLOB_BYTES)
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
const {
handle,
initial: { size: fileSize },
} = source;
admitBlobBytes({ handle, fileSize, signal, limits }, location.blobLength);
const bytes = await readExact(
{ handle, fileSize, signal },
location.blobOffset,
@@ -256,7 +279,15 @@ const locateThinMachO = async (
commandCount: ncmds,
commandBytes: sizeofcmds,
} = framing.value;
if (sizeofcmds > MAX_COMMAND_BYTES) return [];
const limits = carrier.limits ?? DEFAULT_NODE_SEA_READER_LIMITS;
if (sizeofcmds > limits.recognition_load_command_bytes)
throw artifactReaderLimitFailure(
"artifact_limits.node_sea.recognition_load_command_bytes",
limits.recognition_load_command_bytes,
"bytes",
sizeofcmds,
"memory",
);
const commands = await readExact(carrier, base + headerSize, sizeofcmds);
if (commands === undefined) return [];
const found: BlobRange[] = [];
@@ -309,8 +340,11 @@ const locateThinMachO = async (
if (cString(record.nameBytes, 0, 16) !== SEA_SECTION) continue;
const length = Number(record.size);
const fileOffset = record.offset;
if (!Number.isSafeInteger(length) || length > NODE_SEA_MAX_BLOB_BYTES)
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
if (!Number.isSafeInteger(length))
throw limitFailure(
"Node SEA blob length exceeds exact-number representation",
);
admitBlobBytes(carrier, length);
if (fileOffset > sliceSize || length > sliceSize - fileOffset)
throw formatFailure(NODE_SEA_TRUNCATED);
found.push({ offset: base + fileOffset, length });
@@ -468,22 +502,30 @@ const readElfTables = async (
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
table.offset > carrier.fileSize ||
table.count >
Math.floor((carrier.fileSize - table.offset) / table.entrySize)
)
return undefined;
const limits = carrier.limits ?? DEFAULT_NODE_SEA_READER_LIMITS;
if (table.count > limits.recognition_header_count)
throw artifactReaderLimitFailure(
"artifact_limits.node_sea.recognition_header_count",
limits.recognition_header_count,
"count",
table.count,
);
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
table.minimumEntry > carrier.fileSize - offset
)
return undefined;
const entry = await readExact(carrier, offset, table.entrySize);
const entry = await readExact(carrier, offset, table.minimumEntry);
if (entry === undefined) return undefined;
entries.push(entry);
}
@@ -533,8 +575,6 @@ const scanElfNoteTable = async (
if (!parsedHeader.ok)
return failNoteTable(alreadyFound || found.length > 0);
const { namesz, descsz } = parsedHeader.value;
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>;
@@ -549,7 +589,13 @@ const scanElfNoteTable = async (
layout.next < 12
)
return failNoteTable(alreadyFound || found.length > 0);
const name = await readExact(carrier, note.offset + cursor + 12, namesz);
// Only the exact SEA owner matters here. A bounded prefix distinguishes
// it without retaining an unrelated vendor's potentially long name.
const name = await readExact(
carrier,
note.offset + cursor + 12,
Math.min(namesz, RESOURCE_NAME.length + 1),
);
if (name === undefined)
return failNoteTable(alreadyFound || found.length > 0);
const owner = cString(name, 0, name.length);
@@ -559,8 +605,7 @@ const scanElfNoteTable = async (
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);
admitBlobBytes(carrier, descsz);
const blobOffset = note.offset + cursor + layout.descriptor;
if (
blobOffset > carrier.fileSize ||
@@ -644,8 +689,7 @@ const locatePeResources = async (carrier: Carrier): Promise<BlobRange[]> => {
)
return [];
const range = resource.payload.location;
if (range.bytes > NODE_SEA_MAX_BLOB_BYTES)
throw limitFailure(NODE_SEA_BLOB_TOO_LARGE);
admitBlobBytes(carrier, range.bytes);
return [{ offset: range.offset, length: range.bytes }];
});
};