fix(managed): budget decoding before representation expansion

Validation: integrated changes passed formatting, typecheck, lint, resolved module-boundary verification and git diff --check. Tests and real-provider workflows were not run.
This commit is contained in:
morluto committed 2026-10-11 00:03:19 +08:00
1 parent 6d4cefc4f7
commit 0f0d6e4d43
18 files changed
+705 -362

No files matched your search

+14
View File
@@ -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
+1
View File
@@ -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",
+108 -82
View File
@@ -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 }),
),
);
}
};
+106
View File
@@ -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<number, CachedString>();
readonly #digests = new WeakMap<ArrayBufferLike, Map<string, string>>();
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<ManagedMetadataLayout, ManagedDecodeBudget>();
/** 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: 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;
};
+87 -69
View File
@@ -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);
+5 -2
View File
@@ -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)
+10 -1
View File
@@ -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;
+19 -4
View File
@@ -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<typeof signature> => {
managedDecodeBudget(layout).reserve(bytes.length * 256, layout.blob.offset);
return signature(bytes, managedDecodeBudget(layout).digest(bytes, "sha256"));
};
+16 -9
View File
@@ -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,
+26 -9
View File
@@ -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",
);
}
}
+13 -3
View File
@@ -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. */
+17 -17
View File
@@ -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<typeof parseExceptionRegions> =>
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<string, number> = {};
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)
+46 -37
View File
@@ -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),
),
);
+83 -68
View File
@@ -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],
});
}
};
+73 -32
View File
@@ -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,
};
};
+5 -1
View File
@@ -27,7 +27,11 @@ export const readManagedValue = <Value>(
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;
}
+74 -28
View File
@@ -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<ManagedSourceObservation> => {
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<ManagedSourceObservation> => {
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 = (
+2
View File
@@ -4,6 +4,7 @@ import type { FileHandle } from "node:fs/promises";
export const readBoundedFileBytes = async (
handle: FileHandle,
maxBytes: number,
signal?: AbortSignal,
): Promise<Buffer | undefined> => {
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,