diff --git a/docs/managed-code-analysis.md b/docs/managed-code-analysis.md index e899ab6eaa..eb2eeee18f 100644 --- a/docs/managed-code-analysis.md +++ b/docs/managed-code-analysis.md @@ -38,6 +38,20 @@ Inspect separately obtained components explicitly and preserve their identities. A valid implementation marker establishes a candidate native boundary, not recovered native semantics or runtime behavior. +### Resource admission + +The static provider admits regular input files up to 128 MiB before retaining +their bytes. Reads remain bounded if a file grows after admission. Metadata, +CIL decoding, and projected inspection records have a separate conservative +64 MiB representation budget; shared heap strings are cached, but repeated +output occurrences still consume that budget. These are inspection resource +policies, not PE format limits or a hard bound on total V8 heap use. + +Input overflow returns a resource constraint. Decode or projection overflow +reports `resource-limit` with unavailable or partial coverage; omitted facts do +not become empty observations. Ambiguous PE section mappings are rejected +rather than interpreted according to section order. + ## Analysis objective REA's managed-code track is intended to answer five different questions without diff --git a/src/domain/managed/managedArtifact.ts b/src/domain/managed/managedArtifact.ts index 0e2a550d1d..bbc4f63f60 100644 --- a/src/domain/managed/managedArtifact.ts +++ b/src/domain/managed/managedArtifact.ts @@ -282,6 +282,7 @@ const managedFieldAccessSchema = z.object({ const managedParseIssueSchema = z.object({ code: z.enum([ + "resource-limit", "invalid-cli-header", "invalid-directory", "invalid-metadata-root", diff --git a/src/dotnet/ManagedArtifactInspector.ts b/src/dotnet/ManagedArtifactInspector.ts index cae5e7e685..ca48245635 100644 --- a/src/dotnet/ManagedArtifactInspector.ts +++ b/src/dotnet/ManagedArtifactInspector.ts @@ -1,3 +1,7 @@ +import { + admitManagedProjection, + managedDecodeBudget, +} from "./ManagedDecodeBudget.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { managedArtifactInspectionSchema, @@ -107,40 +111,44 @@ const unavailableResult = ( : [ "The PE has no CLI data directory; managed metadata and CIL are unavailable.", ]; - return managedArtifactInspectionSchema.parse({ - ...base(target, bytes, layout), - classification: { - status: malformed ? "malformed" : "not-managed", - container: "pe", - runtime_family: "unknown", - implementation: malformed ? "unknown" : "not-managed", - managed_architecture: "unknown", - evidence: [ - { - code: malformed ? "cli-directory-malformed" : "cli-directory-absent", - detail: limitations[0], - file_offset: layout.cliIssue?.offset ?? null, - }, - ], - }, - metadata: { - status: malformed ? "malformed" : "absent", - version: null, - stream_names: [], - table_row_counts: {}, - }, - module: null, - assembly: null, - target_frameworks: [], - references: [], - resources: [], - attributes: [], - coverage: { - state: malformed ? "partial" : "unavailable", - issues, - }, - limitations, - }); + return managedArtifactInspectionSchema.parse( + admitManagedProjection({ + ...base(target, bytes, layout), + classification: { + status: malformed ? "malformed" : "not-managed", + container: "pe", + runtime_family: "unknown", + implementation: malformed ? "unknown" : "not-managed", + managed_architecture: "unknown", + evidence: [ + { + code: malformed + ? "cli-directory-malformed" + : "cli-directory-absent", + detail: limitations[0], + file_offset: layout.cliIssue?.offset ?? null, + }, + ], + }, + metadata: { + status: malformed ? "malformed" : "absent", + version: null, + stream_names: [], + table_row_counts: {}, + }, + module: null, + assembly: null, + target_frameworks: [], + references: [], + resources: [], + attributes: [], + coverage: { + state: malformed ? "partial" : "unavailable", + issues, + }, + limitations, + }), + ); }; interface RuntimeClassificationInput { @@ -288,7 +296,7 @@ const partialMetadataResult = ({ }: PartialMetadataContext): ManagedArtifactInspection => ({ ...base(target, bytes, layout), classification: { - status: "malformed", + status: issue.code === "resource-limit" ? "managed" : "malformed", container: "pe", runtime_family: (layout.cli?.flags ?? 1) & 1 ? "unknown" : "mixed-clr-native", @@ -303,7 +311,7 @@ const partialMetadataResult = ({ ], }, metadata: { - status: "malformed", + status: issue.code === "resource-limit" ? "partial" : "malformed", version: null, stream_names: [], table_row_counts: {}, @@ -316,7 +324,9 @@ const partialMetadataResult = ({ attributes: [], coverage: { state: "partial", issues: [issue] }, limitations: [ - "CLI metadata could not be read because the PE/CLI structure is malformed or unsupported.", + issue.code === "resource-limit" + ? "CLI metadata inspection stopped at its representation budget; omitted facts are unknown." + : "CLI metadata could not be read because the PE/CLI structure is malformed or unsupported.", ], }); @@ -343,12 +353,14 @@ export const inspectManagedArtifactBytes = ( } catch (cause: unknown) { if (!(cause instanceof ManagedReaderFailure)) throw cause; return managedArtifactInspectionSchema.parse( - partialMetadataResult({ - target, - bytes, - layout, - issue: cause.issue, - }), + admitManagedProjection( + partialMetadataResult({ + target, + bytes, + layout, + issue: cause.issue, + }), + ), ); } const resourceIssues: ManagedParseIssue[] = []; @@ -356,43 +368,57 @@ export const inspectManagedArtifactBytes = ( layout, resourceIssues, ); - const inventory = readManagedMetadataInventory( - bytes, - metadata, - resourceDirectory, - ); - const issues = [...resourceIssues, ...inventory.issues]; - const limitations = [ - ...(issues.length === 0 - ? [] - : [ - "At least one metadata or resource item could not be read; coverage is partial.", - ]), - "Static inspection did not load the assembly, resolve dependencies through a CLR, decompile C#, or execute target code.", - ]; - return managedArtifactInspectionSchema.parse({ - ...base(target, bytes, layout), - classification: { - status: "managed", - container: "pe", - ...runtimeClassification({ layout, metadata, inventory }), - }, - metadata: { - status: issues.length === 0 ? "complete" : "partial", - version: metadata.version, - stream_names: metadata.streamNames, - table_row_counts: managedTableRowCounts(metadata), - }, - module: inventory.module, - assembly: inventory.assembly, - target_frameworks: inventory.targetFrameworks, - references: inventory.references, - resources: inventory.resources, - attributes: inventory.attributes, - coverage: { - state: issues.length === 0 ? "complete" : "partial", - issues, - }, - limitations, - }); + try { + const inventory = readManagedMetadataInventory( + bytes, + metadata, + resourceDirectory, + ); + const issues = [...resourceIssues, ...inventory.issues]; + const limitations = [ + ...(issues.length === 0 + ? [] + : [ + "At least one metadata or resource item could not be read; coverage is partial.", + ]), + "Static inspection did not load the assembly, resolve dependencies through a CLR, decompile C#, or execute target code.", + ]; + return managedArtifactInspectionSchema.parse( + admitManagedProjection( + { + ...base(target, bytes, layout), + classification: { + status: "managed", + container: "pe", + ...runtimeClassification({ layout, metadata, inventory }), + }, + metadata: { + status: issues.length === 0 ? "complete" : "partial", + version: metadata.version, + stream_names: metadata.streamNames, + table_row_counts: managedTableRowCounts(metadata), + }, + module: inventory.module, + assembly: inventory.assembly, + target_frameworks: inventory.targetFrameworks, + references: inventory.references, + resources: inventory.resources, + attributes: inventory.attributes, + coverage: { + state: issues.length === 0 ? "complete" : "partial", + issues, + }, + limitations, + }, + managedDecodeBudget(metadata), + ), + ); + } catch (cause: unknown) { + if (!(cause instanceof ManagedReaderFailure)) throw cause; + return managedArtifactInspectionSchema.parse( + admitManagedProjection( + partialMetadataResult({ target, bytes, layout, issue: cause.issue }), + ), + ); + } }; diff --git a/src/dotnet/ManagedDecodeBudget.ts b/src/dotnet/ManagedDecodeBudget.ts new file mode 100644 index 0000000000..351565e455 --- /dev/null +++ b/src/dotnet/ManagedDecodeBudget.ts @@ -0,0 +1,106 @@ +import { createHash } from "node:crypto"; + +import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { managedFailure } from "./ManagedReaderFailure.js"; + +/** Per-inspection capacity for eagerly retained metadata and its JSON projection. */ +const MAX_METADATA_REPRESENTATION_BYTES = 64 * 1024 * 1024; + +interface CachedString { + readonly value: string; + readonly encodedBytes: number; +} + +/** The layout owns decoding capacity and heap identity for all of its consumers. */ +export class ManagedDecodeBudget { + #remaining = MAX_METADATA_REPRESENTATION_BYTES; + readonly #strings = new Map(); + readonly #digests = new WeakMap>(); + + reserve(bytes: number, offset: number | null): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining) + throw managedFailure( + "resource-limit", + "metadata.representation", + `Managed metadata exceeds the ${String(MAX_METADATA_REPRESENTATION_BYTES)} byte representation budget`, + offset, + ); + this.#remaining -= bytes; + } + + /** UTF-8 cannot decode to more UTF-16 units than bytes; JSON uses at most six bytes per unit. */ + reserveString(encodedBytes: number, offset: number | null): void { + this.reserve(encodedBytes * 6 + 2, offset); + } + + /** Snapshot buffers are immutable within a layout; cache exact backing-buffer ranges. */ + digest(bytes: Buffer, algorithm: "sha256" | "sha1"): string { + let ranges = this.#digests.get(bytes.buffer); + if (ranges === undefined) { + ranges = new Map(); + this.#digests.set(bytes.buffer, ranges); + } + const key = `${algorithm}:${String(bytes.byteOffset)}:${String(bytes.byteLength)}`; + const cached = ranges.get(key); + if (cached !== undefined) return cached; + this.reserve(256, null); + const digest = createHash(algorithm).update(bytes).digest("hex"); + ranges.set(key, digest); + return digest; + } + + cachedString(index: number): CachedString | undefined { + return this.#strings.get(index); + } + + cacheString(index: number, value: string, encodedBytes: number): void { + this.#strings.set(index, { value, encodedBytes }); + } +} + +const budgets = new WeakMap(); + +/** Transfer metadata-root admission capacity to its completed immutable layout. */ +export const attachManagedDecodeBudget = ( + layout: ManagedMetadataLayout, + budget: ManagedDecodeBudget, +): void => { + budgets.set(layout, budget); +}; + +/** Share one owner for the lifetime of the admitted layout, including all inspection facets. */ +export const managedDecodeBudget = ( + layout: ManagedMetadataLayout, +): ManagedDecodeBudget => { + let budget = budgets.get(layout); + if (budget === undefined) { + budget = new ManagedDecodeBudget(); + budgets.set(layout, budget); + } + return budget; +}; + +/** Admit repeated values too: shared heap strings still expand at the JSON boundary. */ +export const admitManagedProjection = ( + value: Value, + budget: ManagedDecodeBudget = new ManagedDecodeBudget(), +): Value => { + const visit = (item: unknown): void => { + if (typeof item === "string") { + budget.reserveString(item.length, null); + } else if (Array.isArray(item)) { + budget.reserve(item.length + 2, null); + for (const child of item) visit(child); + } else if (item !== null && typeof item === "object") { + budget.reserve(2, null); + for (const key of Object.keys(item)) { + budget.reserve(key.length * 6 + 4, null); + visit(Reflect.get(item, key)); + } + } else { + budget.reserve(32, null); + } + }; + visit(value); + return value; +}; diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index dd124b2700..e9862d3fa2 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -1,3 +1,7 @@ +import { + admitManagedProjection, + managedDecodeBudget, +} from "./ManagedDecodeBudget.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { managedMemberInspectionSchema, @@ -31,40 +35,47 @@ const unavailable = ( bytes: Buffer, issue: ManagedParseIssue | null, ): ManagedMemberInspection => - managedMemberInspectionSchema.parse({ - artifact: { - path: target.path, - sha256: target.sha256, - byte_length: bytes.length, - format: "pe", - }, - module: null, - metadata: { - status: issue === null ? "absent" : "malformed", - version: null, - table_row_counts: {}, - }, - identity_scope: { - token_identity: "build-local", - requires_artifact_sha256: target.sha256, - requires_mvid: null, - }, - types: [], - fields: [], - methods: [], - member_refs: [], - call_edges: [], - field_accesses: [], - coverage: { - state: "unavailable", - issues: issue === null ? [] : [issue], - }, - limitations: [ - issue === null - ? "The PE has no admitted CLI metadata; managed member inspection is unavailable." - : "The CLI metadata could not be admitted; managed member inspection is unavailable.", - ], - }); + managedMemberInspectionSchema.parse( + admitManagedProjection({ + artifact: { + path: target.path, + sha256: target.sha256, + byte_length: bytes.length, + format: "pe", + }, + module: null, + metadata: { + status: + issue === null + ? "absent" + : issue.code === "resource-limit" + ? "partial" + : "malformed", + version: null, + table_row_counts: {}, + }, + identity_scope: { + token_identity: "build-local", + requires_artifact_sha256: target.sha256, + requires_mvid: null, + }, + types: [], + fields: [], + methods: [], + member_refs: [], + call_edges: [], + field_accesses: [], + coverage: { + state: "unavailable", + issues: issue === null ? [] : [issue], + }, + limitations: [ + issue === null + ? "The PE has no admitted CLI metadata; managed member inspection is unavailable." + : "The CLI metadata could not be admitted; managed member inspection is unavailable.", + ], + }), + ); const readMemberInventory = ( bytes: Buffer, @@ -132,41 +143,48 @@ export const inspectManagedMembersBytes = ( fields.core, memberRefs.core, ); - return managedMemberInspectionSchema.parse({ - artifact: { - path: target.path, - sha256: target.sha256, - byte_length: bytes.length, - format: "pe", - }, - module: inventory.module, - metadata: { - status: issues.length === 0 ? "complete" : "partial", - version: layout.version, - table_row_counts: managedTableRowCounts(layout), - }, - identity_scope: { - token_identity: "build-local", - requires_artifact_sha256: target.sha256, - requires_mvid: inventory.module?.mvid ?? null, - }, - types: parsedTypes.types, - fields: fields.fields, - methods: methods.methods, - member_refs: memberRefs.refs, - call_edges: related.callEdges, - field_accesses: related.fieldAccesses, - coverage: { - state: - issues.length === 0 && !incompleteMembers ? "complete" : "partial", - issues: coverageIssues, - }, - limitations: [ - "Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", - "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", - "Signatures are decoded for common ECMA-335 primitive, class, valuetype, pointer, byref, array, and generic variable forms; unsupported forms retain raw signature hashes.", - ], - }); + return managedMemberInspectionSchema.parse( + admitManagedProjection( + { + artifact: { + path: target.path, + sha256: target.sha256, + byte_length: bytes.length, + format: "pe", + }, + module: inventory.module, + metadata: { + status: issues.length === 0 ? "complete" : "partial", + version: layout.version, + table_row_counts: managedTableRowCounts(layout), + }, + identity_scope: { + token_identity: "build-local", + requires_artifact_sha256: target.sha256, + requires_mvid: inventory.module?.mvid ?? null, + }, + types: parsedTypes.types, + fields: fields.fields, + methods: methods.methods, + member_refs: memberRefs.refs, + call_edges: related.callEdges, + field_accesses: related.fieldAccesses, + coverage: { + state: + issues.length === 0 && !incompleteMembers + ? "complete" + : "partial", + issues: coverageIssues, + }, + limitations: [ + "Metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", + "CIL instruction anchors are decoded from file-backed method bodies only; no target assembly is loaded or executed.", + "Signatures are decoded for common ECMA-335 primitive, class, valuetype, pointer, byref, array, and generic variable forms; unsupported forms retain raw signature hashes.", + ], + }, + managedDecodeBudget(layout), + ), + ); } catch (cause: unknown) { if (cause instanceof ManagedReaderFailure) return unavailable(target, bytes, cause.issue); diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index fab92b5628..f867bbd022 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -405,10 +405,13 @@ const callingConvention = (value: number): string => { return flags.length === 0 ? name : `${name} ${flags.join(" ")}`; }; -export const signature = (blob: Buffer): ManagedSignature => { +export const signature = ( + blob: Buffer, + rawSha256?: string, +): ManagedSignature => { const raw = { raw_length: blob.length, - raw_sha256: sha256Bytes(blob), + raw_sha256: rawSha256 ?? sha256Bytes(blob), }; try { if (blob.length === 0) diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index 49f240a887..43a678dedf 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -1,3 +1,4 @@ +import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; import type { ManagedExceptionRegion, ManagedInstructionAnchor, @@ -434,6 +435,7 @@ export const parseExceptionRegions = ( bytes: Buffer, offset: number, methodEnd: number, + budget?: ManagedDecodeBudget, ): ExceptionRegionParseResult => { const regions: ManagedExceptionRegion[] = []; let sectionOffset = offset; @@ -487,7 +489,14 @@ export const parseExceptionRegions = ( ); const count = (size - 4) / clauseSize; - regions.push(...readExceptionClauses(bytes, sectionOffset + 4, count, fat)); + budget?.reserve(count * 256, sectionOffset); + for (const region of readExceptionClauses( + bytes, + sectionOffset + 4, + count, + fat, + )) + regions.push(region); hasMoreSections = (kind & 0x80) !== 0; sectionOffset += size; diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index 8b28154edf..18a67dbafa 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -1,3 +1,4 @@ +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; import type { ManagedPeLayout } from "./ManagedPeReader.js"; import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; @@ -64,7 +65,7 @@ export const parseFields = ( declaring_type: declared?.fullName ?? null, name, flags, - signature: signature(sig), + signature: budgetedSignature(sig, layout), }); core.set(token, { token, name }); }, issues); @@ -129,7 +130,7 @@ export const parseMemberRefs = ( layout.rowCounts, ), name, - signature: signature(sig), + signature: budgetedSignature(sig, layout), }); core.set(token, { token, name }); }, issues); @@ -189,8 +190,14 @@ export const parseMethods = ({ rva, impl_flags: implFlags, flags, - signature: signature(sig), - body: methodBody(bytes, pe, rva, { implFlags, flags }), + signature: budgetedSignature(sig, layout), + body: methodBody( + bytes, + pe, + rva, + { implFlags, flags }, + managedDecodeBudget(layout), + ), }); core.set(token, { token, @@ -258,3 +265,11 @@ export const edges = ( } return { callEdges, fieldAccesses }; }; + +const budgetedSignature = ( + bytes: Buffer, + layout: ManagedMetadataLayout, +): ReturnType => { + managedDecodeBudget(layout).reserve(bytes.length * 256, layout.blob.offset); + return signature(bytes, managedDecodeBudget(layout).digest(bytes, "sha256")); +}; diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index ab59caa213..ca062b8684 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; + import { metadataRowOffset, type ManagedMetadataLayout, @@ -117,6 +119,11 @@ export const readMetadataString = ( layout.strings.offset + Math.max(index, 0), ); const start = layout.strings.offset + index; + const cached = managedDecodeBudget(layout).cachedString(index); + if (cached !== undefined && cached.encodedBytes <= heapExtent) { + managedDecodeBudget(layout).reserveString(cached.encodedBytes, start); + return cached.value; + } const maximum = Math.min( layout.strings.offset + layout.strings.size, start + heapExtent + 1, @@ -130,10 +137,14 @@ export const readMetadataString = ( "String heap item has no terminator within #Strings", start, ); + managedDecodeBudget(layout).reserveString(end - start, start); try { - return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes.subarray(start, end), - ); + const value = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes.subarray(start, end)); + managedDecodeBudget(layout).cacheString(index, value, end - start); + return value; } catch (cause: unknown) { void cause; throw managedFailure( @@ -218,12 +229,6 @@ export const metadataToken = (table: number, row: number): string => export const sha256Bytes = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex"); -/** Derive the conventional strong-name token from a full public key. */ -export const strongNameToken = (publicKey: Buffer): string => - Buffer.from(createHash("sha1").update(publicKey).digest().subarray(-8)) - .reverse() - .toString("hex"); - /** Create a cursor bounded to one admitted metadata table row. */ export const metadataRowCursor = ( bytes: Buffer, @@ -240,6 +245,8 @@ export const metadataRowCursor = ( "Metadata table is absent", start, ); + // Reserve per-row object/index bookkeeping before readers retain decoded facts. + managedDecodeBudget(layout).reserve(256, start); return new MetadataRowCursor( bytes, start, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 596cb519c6..32b5f8d251 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -1,3 +1,5 @@ +import { managedDecodeBudget } from "./ManagedDecodeBudget.js"; + import type { ManagedArtifactInspection, ManagedParseIssue, @@ -14,8 +16,6 @@ import { readMetadataBlob, readMetadataGuid, readMetadataString, - sha256Bytes, - strongNameToken, } from "./ManagedMetadataHeaps.js"; import { managedFailure } from "./ManagedReaderFailure.js"; import type { ManagedResourceDirectory } from "./ManagedMetadataInventory.js"; @@ -29,15 +29,22 @@ type CustomAttribute = ManagedArtifactInspection["attributes"][number]; const publicKeyIdentity = ( bytes: Buffer, kind: "public-key" | "public-key-token", + layout: ManagedMetadataLayout, ): AssemblyIdentity["public_key"] => ({ kind: bytes.length === 0 ? "none" : kind, byte_length: bytes.length, - sha256: bytes.length === 0 ? null : sha256Bytes(bytes), + sha256: + bytes.length === 0 + ? null + : managedDecodeBudget(layout).digest(bytes, "sha256"), token: bytes.length === 0 ? null : kind === "public-key" - ? strongNameToken(bytes) + ? Buffer.from(managedDecodeBudget(layout).digest(bytes, "sha1"), "hex") + .subarray(-8) + .reverse() + .toString("hex") : bytes.length === 8 ? bytes.toString("hex") : null, @@ -127,6 +134,7 @@ export const readAssembly = ( public_key: publicKeyIdentity( key, (flags & 1) === 0 ? "public-key-token" : "public-key", + layout, ), token: metadataToken(32, 1), row_offset: table.offset, @@ -179,8 +187,12 @@ export const readAssemblyReference = ( public_key_or_token: publicKeyIdentity( keyOrToken, (flags & 1) === 0 ? "public-key-token" : "public-key", + layout, ), - hash_value_sha256: hashValue.length === 0 ? null : sha256Bytes(hashValue), + hash_value_sha256: + hashValue.length === 0 + ? null + : managedDecodeBudget(layout).digest(hashValue, "sha256"), hash_value_length: hashValue.length, token: metadataToken(35, row), row_offset: metadataRowOffset(layout, 35, row), @@ -338,13 +350,17 @@ const decodeCompressedLength = ( return undefined; }; -const decodeFixedString = (blob: Buffer): string | null => { +const decodeFixedString = ( + blob: Buffer, + layout: ManagedMetadataLayout, +): string | null => { if (blob.length < 3 || blob.readUInt16LE(0) !== 1 || blob[2] === 0xff) return null; const length = decodeCompressedLength(blob, 2); if (length === undefined) return null; const start = 2 + length.prefix; if (start > blob.length - length.length) return null; + managedDecodeBudget(layout).reserveString(length.length, layout.blob.offset); try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( blob.subarray(start, start + length.length), @@ -427,10 +443,10 @@ export const readCustomAttribute = ( ), type_name: typeName, value_length: value.length, - value_sha256: sha256Bytes(value), + value_sha256: managedDecodeBudget(layout).digest(value, "sha256"), decoded_fixed_string: typeName === "System.Runtime.Versioning.TargetFrameworkAttribute" - ? decodeFixedString(value) + ? decodeFixedString(value, layout) : null, token: metadataToken(12, row), row_offset: cursor.start, @@ -505,8 +521,9 @@ export const readResource = ({ detail: "Embedded resource length leaves the CLI resources directory", }); } else { - dataSha256 = sha256Bytes( + dataSha256 = managedDecodeBudget(layout).digest( bytes.subarray(start + 4, start + 4 + dataLength), + "sha256", ); } } diff --git a/src/dotnet/ManagedMetadataLayout.ts b/src/dotnet/ManagedMetadataLayout.ts index 19a8740b39..5a34e25122 100644 --- a/src/dotnet/ManagedMetadataLayout.ts +++ b/src/dotnet/ManagedMetadataLayout.ts @@ -1,3 +1,7 @@ +import { + attachManagedDecodeBudget, + ManagedDecodeBudget, +} from "./ManagedDecodeBudget.js"; import { managedFailure } from "./ManagedReaderFailure.js"; import { computeRowSize, @@ -208,6 +212,7 @@ interface ParsedStreams { const readMetadataStreams = ( bytes: Buffer, rootOffset: number, + budget: ManagedDecodeBudget, ): ParsedStreams => { if (bytes.readUInt32LE(0) !== 0x424a_5342) throw managedFailure( @@ -220,8 +225,10 @@ const readMetadataStreams = ( requireRange(bytes, 16, versionLength, "metadata.version"); const versionBytes = bytes.subarray(16, 16 + versionLength); const zero = versionBytes.indexOf(0); + const decodedVersionLength = zero < 0 ? versionBytes.length : zero; + budget.reserveString(decodedVersionLength, rootOffset + 16); const version = versionBytes - .subarray(0, zero < 0 ? versionBytes.length : zero) + .subarray(0, decodedVersionLength) .toString("utf8"); let cursor = align4(16 + versionLength); requireRange(bytes, cursor, 4, "metadata.stream-count"); @@ -390,7 +397,8 @@ export const readManagedMetadataLayout = ( rootOffset, ); const bytes = artifact.subarray(rootOffset, rootOffset + size); - const { streams, version } = readMetadataStreams(bytes, rootOffset); + const budget = new ManagedDecodeBudget(); + const { streams, version } = readMetadataStreams(bytes, rootOffset, budget); const { tableBytes, tablesOffset, heaps, valid } = readTablesHeader( artifact, streams, @@ -403,7 +411,7 @@ export const readManagedMetadataLayout = ( valid, tablesOffset, }); - return { + const layout: ManagedMetadataLayout = { rootOffset, size, version, @@ -420,6 +428,8 @@ export const readManagedMetadataLayout = ( tableIndexSize: (index) => tableSize(index, rowCounts), codedIndexSize: (name) => codedSize(name, rowCounts), }; + attachManagedDecodeBudget(layout, budget); + return layout; }; /** Resolve a one-based metadata row to its exact file offset. */ diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index 69626e8284..9985b718b9 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -1,3 +1,4 @@ +import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js"; import type { ManagedPeLayout } from "./ManagedPeReader.js"; import type { ManagedMethodBody } from "./ManagedMemberInspectorCore.js"; import { @@ -62,19 +63,6 @@ const readMethodBodyHeader = ( }; }; -const readExceptionRegions = ( - bytes: Buffer, - sectionOffset: number, - methodEnd: number, - ilSize: number, - instructionOffsets: readonly number[], -): ReturnType => - validateExceptionRegionRanges( - parseExceptionRegions(bytes, sectionOffset, methodEnd), - ilSize, - instructionOffsets, - ); - /** Decode admitted managed CIL, retaining unavailable implementation metadata as partial. */ export const methodBody = ( bytes: Buffer, @@ -84,6 +72,7 @@ export const methodBody = ( implFlags: 0, flags: 0, }, + budget?: ManagedDecodeBudget, ): ManagedMethodBody => { if (rva === 0) return emptyMethodBody(rva, "absent", null); if ( @@ -108,6 +97,9 @@ export const methodBody = ( if (ilOffset > bytes.length - header.ilSize) throw new RangeError("Method IL bytes leave artifact"); const il = bytes.subarray(ilOffset, ilOffset + header.ilSize); + // One byte can produce an instruction record, start index, normalized tuple, + // and anchor. Reserve conservative bookkeeping capacity before decoding. + budget?.reserve(il.length * 256, ilOffset); const decoded = decodeInstructions(il); const opcodeCounts: Record = {}; for (const instruction of decoded.parsed) @@ -135,10 +127,13 @@ export const methodBody = ( const sectionOffset = (methodEnd + 3) & ~3; const exceptionRegions = header.format === "fat" && (header.flags & 8) !== 0 - ? readExceptionRegions( - bytes, - sectionOffset, - offset + methodExtent, + ? validateExceptionRegionRanges( + parseExceptionRegions( + bytes, + sectionOffset, + offset + methodExtent, + budget, + ), header.ilSize, decoded.instructionStarts, ) @@ -179,6 +174,11 @@ export const methodBody = ( : null), }; } catch (cause: unknown) { + if ( + cause instanceof ManagedReaderFailure && + cause.issue.code === "resource-limit" + ) + throw cause; if ( !(cause instanceof ManagedReaderFailure) && !(cause instanceof RangeError) diff --git a/src/dotnet/ManagedNativeBoundaryHelpers.ts b/src/dotnet/ManagedNativeBoundaryHelpers.ts index 26db5ea527..4c7498a1b6 100644 --- a/src/dotnet/ManagedNativeBoundaryHelpers.ts +++ b/src/dotnet/ManagedNativeBoundaryHelpers.ts @@ -1,3 +1,7 @@ +import { + admitManagedProjection, + managedDecodeBudget, +} from "./ManagedDecodeBudget.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { managedNativeBoundaryInspectionSchema, @@ -427,40 +431,45 @@ export const buildNativeBoundaryInspection = ({ native, issues, }: BoundaryInspectionContext): ManagedNativeBoundaryInspection => - managedNativeBoundaryInspectionSchema.parse({ - artifact: { - path: target.path, - sha256: target.sha256, - byte_length: bytes.length, - format: "pe", - }, - module: inventory.module, - metadata: { - status: issues.length === 0 ? "complete" : "partial", - version: layout.version, - table_row_counts: managedTableRowCounts(layout), - }, - identity_scope: { - token_identity: "build-local", - requires_artifact_sha256: target.sha256, - requires_mvid: inventory.module?.mvid ?? null, - }, - cli_native: native, - module_refs: moduleRefs, - pinvoke_imports: imports, - native_implementations: implementations, - summary: nativeBoundarySummary(native, { - module_ref_count: moduleRefs.length, - pinvoke_import_count: imports.length, - native_implementation_count: implementations.length, - }), - coverage: { - state: issues.length === 0 ? "complete" : "partial", - issues, - }, - limitations: [ - "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", - "Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", - "ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.", - ], - }); + managedNativeBoundaryInspectionSchema.parse( + admitManagedProjection( + { + artifact: { + path: target.path, + sha256: target.sha256, + byte_length: bytes.length, + format: "pe", + }, + module: inventory.module, + metadata: { + status: issues.length === 0 ? "complete" : "partial", + version: layout.version, + table_row_counts: managedTableRowCounts(layout), + }, + identity_scope: { + token_identity: "build-local", + requires_artifact_sha256: target.sha256, + requires_mvid: inventory.module?.mvid ?? null, + }, + cli_native: native, + module_refs: moduleRefs, + pinvoke_imports: imports, + native_implementations: implementations, + summary: nativeBoundarySummary(native, { + module_ref_count: moduleRefs.length, + pinvoke_import_count: imports.length, + native_implementation_count: implementations.length, + }), + coverage: { + state: issues.length === 0 ? "complete" : "partial", + issues, + }, + limitations: [ + "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", + "Managed metadata tokens are build-local coordinates and are only meaningful with the reported artifact SHA-256 and MVID.", + "ReadyToRun, NativeAOT, C++/CLI, and IL2CPP native semantics require separately selected native-provider evidence; this tool does not translate managed tokens into native addresses.", + ], + }, + managedDecodeBudget(layout), + ), + ); diff --git a/src/dotnet/ManagedNativeBoundaryInspector.ts b/src/dotnet/ManagedNativeBoundaryInspector.ts index 17a4e9906a..22fd92ea0c 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.ts @@ -1,3 +1,4 @@ +import { admitManagedProjection } from "./ManagedDecodeBudget.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { managedNativeBoundaryInspectionSchema, @@ -47,39 +48,45 @@ const emptyInspection = ( readonly issues?: readonly ManagedParseIssue[]; } = {}, ): ManagedNativeBoundaryInspection => { - return managedNativeBoundaryInspectionSchema.parse({ - artifact: { - path: target.path, - sha256: target.sha256, - byte_length: bytes.length, - format: "pe", - }, - module: null, - metadata: { - status: classification === "malformed" ? "malformed" : "absent", - version: null, - table_row_counts: {}, - }, - identity_scope: { - token_identity: "build-local", - requires_artifact_sha256: target.sha256, - requires_mvid: null, - }, - cli_native: native, - module_refs: [], - pinvoke_imports: [], - native_implementations: [], - summary: nativeBoundarySummary(native, { - module_ref_count: 0, - pinvoke_import_count: 0, - native_implementation_count: 0, + return managedNativeBoundaryInspectionSchema.parse( + admitManagedProjection({ + artifact: { + path: target.path, + sha256: target.sha256, + byte_length: bytes.length, + format: "pe", + }, + module: null, + metadata: { + status: issues.some((issue) => issue.code === "resource-limit") + ? "partial" + : classification === "malformed" + ? "malformed" + : "absent", + version: null, + table_row_counts: {}, + }, + identity_scope: { + token_identity: "build-local", + requires_artifact_sha256: target.sha256, + requires_mvid: null, + }, + cli_native: native, + module_refs: [], + pinvoke_imports: [], + native_implementations: [], + summary: nativeBoundarySummary(native, { + module_ref_count: 0, + pinvoke_import_count: 0, + native_implementation_count: 0, + }), + coverage: { state: "unavailable", issues }, + limitations: [ + "No CLI metadata was admitted; native boundary declarations are unavailable.", + "Static inspection does not load or execute target code, so native export resolution is not performed.", + ], }), - coverage: { state: "unavailable", issues }, - limitations: [ - "No CLI metadata was admitted; native boundary declarations are unavailable.", - "Static inspection does not load or execute target code, so native export resolution is not performed.", - ], - }); + ); }; const readBoundaryInventory = ( @@ -142,40 +149,48 @@ export const inspectManagedNativeBoundariesBytes = ( issues: [cause.issue], }); } - const heapExtent = Math.max(layout.strings.size, layout.blob.size); - issues.push(...inventory.issues); - const moduleRefs = parseModuleRefs(bytes, layout, heapExtent, issues); - const members = new Map([ - ...parseFields(bytes, layout, heapExtent, issues), - ...parseMethods(bytes, layout, heapExtent, issues), - ]); - const imports = parseImplMaps({ - bytes, - layout, - heapExtent, - modules: moduleRefs, - members, - issues, - }); - const pinvokeTokens = new Set( - imports - .map(({ member_token }) => member_token) - .filter((token): token is string => token !== null), - ); - const implementations = nativeImplementations( - members.values(), - pinvokeTokens, - ); - return buildNativeBoundaryInspection({ - target, - bytes, - pe, - layout, - inventory, - moduleRefs, - imports, - implementations, - native: cliNative(pe), - issues, - }); + try { + const heapExtent = Math.max(layout.strings.size, layout.blob.size); + issues.push(...inventory.issues); + const moduleRefs = parseModuleRefs(bytes, layout, heapExtent, issues); + const members = new Map([ + ...parseFields(bytes, layout, heapExtent, issues), + ...parseMethods(bytes, layout, heapExtent, issues), + ]); + const imports = parseImplMaps({ + bytes, + layout, + heapExtent, + modules: moduleRefs, + members, + issues, + }); + const pinvokeTokens = new Set( + imports + .map(({ member_token }) => member_token) + .filter((token): token is string => token !== null), + ); + const implementations = nativeImplementations( + members.values(), + pinvokeTokens, + ); + return buildNativeBoundaryInspection({ + target, + bytes, + pe, + layout, + inventory, + moduleRefs, + imports, + implementations, + native: cliNative(pe), + issues, + }); + } catch (cause: unknown) { + if (!(cause instanceof ManagedReaderFailure)) throw cause; + return emptyInspection(target, bytes, "malformed", { + native: cliNative(pe), + issues: [...issues, cause.issue], + }); + } }; diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index c888a2b4bc..8100a521f3 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -121,31 +121,87 @@ const architectureFor = (machine: number): ManagedPeLayout["architecture"] => { } }; -const mapRva = (mapping: RvaMapping): number => { +/** Resolve one section identity for both exact reads and available-byte queries. */ +const resolveRva = ( + mapping: RvaMapping, +): { readonly offset: number; readonly available: number } => { const { bytes, sections, rva, size, scope } = mapping; - if (rva === 0 && size === 0) return 0; - for (const section of sections) { - const mappedSize = Math.max(section.virtualSize, section.rawSize); - if (rva < section.virtualAddress) continue; + if ( + !Number.isSafeInteger(rva) || + !Number.isSafeInteger(size) || + rva < 0 || + size < 0 || + rva > 0xffff_ffff || + (size > 0 && size > 0x1_0000_0000 - rva) + ) + throw managedFailure( + "invalid-directory", + scope, + `${scope} has an invalid RVA range`, + ); + const selected = sections.find((section) => { const within = rva - section.virtualAddress; - if (within > mappedSize || size > mappedSize - within) continue; - if (within > section.rawSize || size > section.rawSize - within) + const mappedSize = Math.max(section.virtualSize, section.rawSize); + return ( + mappedSize > 0 && + within >= 0 && + within <= mappedSize && + size <= mappedSize - within + ); + }); + if (selected === undefined) + throw managedFailure( + "invalid-directory", + scope, + `${scope} RVA is not covered by one PE section`, + ); + for (const section of sections) { + if ( + section === selected || + Math.max(section.virtualSize, section.rawSize) === 0 + ) + continue; + const end = + section.virtualAddress + Math.max(section.virtualSize, section.rawSize); + const intersects = + size === 0 + ? rva >= section.virtualAddress && rva < end + : rva < end && rva + size > section.virtualAddress; + if (intersects) throw managedFailure( "invalid-directory", scope, - `${scope} occupies virtual bytes without file-backed data`, + `${scope} RVA range has ambiguous overlapping PE section mappings`, ); - const offset = add(section.rawOffset, within, scope); - requireRange(bytes, offset, size, scope); - return offset; } - throw managedFailure( - "invalid-directory", - scope, - `${scope} RVA is not covered by a PE section`, + const within = rva - selected.virtualAddress; + if (within > selected.rawSize || size > selected.rawSize - within) + throw managedFailure( + "invalid-directory", + scope, + `${scope} occupies virtual bytes without file-backed data`, + ); + const offset = add(selected.rawOffset, within, scope); + requireRange(bytes, offset, size, scope); + let available = Math.min( + selected.rawSize - within, + bytes.length - offset, + 0x1_0000_0000 - rva, ); + // A future overlap ends the uniquely mapped extent even if the current byte is unique. + for (const section of sections) + if ( + section !== selected && + section.virtualAddress > rva && + Math.max(section.virtualSize, section.rawSize) > 0 + ) + available = Math.min(available, section.virtualAddress - rva); + return { offset, available }; }; +const mapRva = (mapping: RvaMapping): number => + mapping.rva === 0 && mapping.size === 0 ? 0 : resolveRva(mapping).offset; + const readDirectory = (bytes: Buffer, offset: number): PeDataDirectory => { requireRange(bytes, offset, 8, "pe.data-directory"); return { @@ -356,22 +412,7 @@ export const readManagedPeLayout = (bytes: Buffer): ManagedPeLayout => { cliIssue, rvaToOffset: (rva, size, scope) => mapRva({ bytes, sections, rva, size, scope }), - rvaAvailableBytes: (rva, scope) => { - mapRva({ bytes, sections, rva, size: 1, scope }); - for (const section of sections) { - if (rva < section.virtualAddress) continue; - const within = rva - section.virtualAddress; - if (within < section.rawSize) - return Math.min( - section.rawSize - within, - bytes.length - section.rawOffset - within, - ); - } - throw managedFailure( - "invalid-directory", - scope, - `${scope} RVA is not covered by file-backed PE section data`, - ); - }, + rvaAvailableBytes: (rva, scope) => + resolveRva({ bytes, sections, rva, size: 1, scope }).available, }; }; diff --git a/src/dotnet/ManagedReaderFailure.ts b/src/dotnet/ManagedReaderFailure.ts index fe8fb2fa53..5e16c0ca49 100644 --- a/src/dotnet/ManagedReaderFailure.ts +++ b/src/dotnet/ManagedReaderFailure.ts @@ -27,7 +27,11 @@ export const readManagedValue = ( try { return operation(); } catch (cause: unknown) { - if (!(cause instanceof ManagedReaderFailure)) throw cause; + if ( + !(cause instanceof ManagedReaderFailure) || + cause.issue.code === "resource-limit" + ) + throw cause; issues.push(cause.issue); return undefined; } diff --git a/src/dotnet/ManagedStaticProvider.ts b/src/dotnet/ManagedStaticProvider.ts index 68f0c06962..116f93382a 100644 --- a/src/dotnet/ManagedStaticProvider.ts +++ b/src/dotnet/ManagedStaticProvider.ts @@ -1,6 +1,4 @@ import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { readFile } from "node:fs/promises"; import { createAnalysisExecution, @@ -18,11 +16,12 @@ import { managedTargetInputSchema, type ManagedToolName, } from "../contracts/managed/managedToolContracts.js"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -38,6 +37,23 @@ import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js"; +import { openRegularFile } from "../filesystem/RegularFile.js"; +import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; + +// This provider eagerly retains the full PE snapshot. Keep its input resource +// policy separate from decoded metadata/output capacity; it is not a PE format limit. +const MAX_MANAGED_SNAPSHOT_BYTES = 128 * 1024 * 1024; + +const snapshotCapacityError = ( + operation: string, +): AnalysisResourceConstraintError => + new AnalysisResourceConstraintError( + operation, + "memory", + "Managed static inspection requires an eagerly retained PE snapshot within its input byte budget.", + { max_snapshot_bytes: MAX_MANAGED_SNAPSHOT_BYTES }, + ); + /** Execution-free managed PE/CLI auxiliary provider. */ export class ManagedStaticProvider implements AnalysisProvider { readonly #capabilities: readonly CapabilityDescriptor[] = @@ -90,8 +106,16 @@ class ManagedStaticClient implements AnalysisClient { const snapshot = this.#snapshotBytes; const observed = snapshot === undefined - ? await readManagedSnapshot(this.target.path, options?.signal) - : await hashManagedSource(this.target.path, options?.signal); + ? await readManagedSnapshot( + this.target.path, + operation, + options?.signal, + ) + : await hashManagedSource( + this.target.path, + operation, + options?.signal, + ); if (observed.sha256 !== this.target.sha256) return err( new EvidenceIntegrityError( @@ -119,6 +143,7 @@ class ManagedStaticClient implements AnalysisClient { } catch (cause: unknown) { if (options?.signal?.aborted === true) return err(new AnalysisCancelledError(operation)); + if (cause instanceof AnalysisError) return err(cause); return err( new ProviderAdapterError(MANAGED_STATIC_PROVIDER.id, operation, { cause, @@ -141,38 +166,59 @@ interface ManagedSourceObservation { const readManagedSnapshot = async ( path: string, + operation: string, signal?: AbortSignal, ): Promise => { - const bytes = await readFile( - path, - signal === undefined ? undefined : { signal }, - ); - return { - byteLength: bytes.length, - sha256: createHash("sha256").update(bytes).digest("hex"), - bytes, - }; + const handle = await openRegularFile(path, { symlinks: "follow", signal }); + try { + const metadata = await handle.stat(); + if (metadata.size > MAX_MANAGED_SNAPSHOT_BYTES) + throw snapshotCapacityError(operation); + const bytes = await readBoundedFileBytes( + handle, + MAX_MANAGED_SNAPSHOT_BYTES, + signal, + ); + if (bytes === undefined) throw snapshotCapacityError(operation); + signal?.throwIfAborted(); + return { + byteLength: bytes.length, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes, + }; + } finally { + await handle.close(); + } }; const hashManagedSource = async ( path: string, + operation: string, signal?: AbortSignal, ): Promise => { - const digest = createHash("sha256"); - let byteLength = 0; - const stream = createReadStream( - path, - signal === undefined ? undefined : { signal }, - ); - for await (const chunk of stream) { - if (!Buffer.isBuffer(chunk)) - throw new TypeError("Managed source stream returned non-buffer data"); - if (chunk.length > Number.MAX_SAFE_INTEGER - byteLength) - throw new RangeError("Managed source byte length overflowed"); - byteLength += chunk.length; - digest.update(chunk); + const handle = await openRegularFile(path, { symlinks: "follow", signal }); + try { + if ((await handle.stat()).size > MAX_MANAGED_SNAPSHOT_BYTES) + throw snapshotCapacityError(operation); + const digest = createHash("sha256"); + let byteLength = 0; + const stream = handle.createReadStream({ + autoClose: false, + ...(signal === undefined ? {} : { signal }), + }); + for await (const chunk of stream) { + signal?.throwIfAborted(); + if (!Buffer.isBuffer(chunk)) + throw new TypeError("Managed source stream returned non-buffer data"); + if (chunk.length > MAX_MANAGED_SNAPSHOT_BYTES - byteLength) + throw snapshotCapacityError(operation); + byteLength += chunk.length; + digest.update(chunk); + } + return { byteLength, sha256: digest.digest("hex") }; + } finally { + await handle.close(); } - return { byteLength, sha256: digest.digest("hex") }; }; const isManagedOperation = ( diff --git a/src/process/BoundedFileBytes.ts b/src/process/BoundedFileBytes.ts index 93433b41c7..90b6a41a66 100644 --- a/src/process/BoundedFileBytes.ts +++ b/src/process/BoundedFileBytes.ts @@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises"; export const readBoundedFileBytes = async ( handle: FileHandle, maxBytes: number, + signal?: AbortSignal, ): Promise => { if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("File byte limit must be a nonnegative safe integer"); @@ -11,6 +12,7 @@ export const readBoundedFileBytes = async ( const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1)); let total = 0; for (;;) { + signal?.throwIfAborted(); const { bytesRead } = await handle.read( buffer, 0,