merge: consolidate upstream managed token facts

This commit is contained in:
morluto committed 2026-10-24 05:25:09 +08:00
commit 8d3ed58cb7
15 files changed
+595 -215

No files matched your search

+139
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js";
import {
buildManagedPeFixture,
fixtureUserStringHeap,
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
import { parseExceptionRegions } from "./ManagedMemberInstructionDecoder.js";
@@ -490,6 +491,65 @@ describe("managed complete operand decoding", () => {
});
});
it("retains named references and loaded literals before a truncated operand", () => {
const { heap, tokens } = fixtureUserStringHeap(["observed"]);
const literalToken = tokens[0]!;
const il = Buffer.from([
0x28, 1, 0, 0, 0x0a, 0x7e, 1, 0, 0, 0x04, 0x26, 0x72, 0, 0, 0, 0, 0x26,
0x20,
]);
il.writeUInt32LE(Number.parseInt(literalToken, 16), 12);
const bytes = buildManagedPeFixture({
memberRefParentRaw: 8,
memberRefName: "ObservedCall",
fieldName: "ObservedField",
ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]),
userStringHeap: heap,
});
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "malformed",
il_size: il.length,
il_sha256: createHash("sha256").update(il).digest("hex"),
normalized_il_sha256: null,
decoded_instruction_count: 5,
anchors: [
{ opcode: "call", operand: "0x0a000001" },
{ opcode: "ldsfld", operand: "0x04000001" },
{ opcode: "pop", operand: null },
{ opcode: "ldstr", operand: literalToken },
{ opcode: "pop", operand: null },
],
issue: "Instruction ldc.i4 at IL offset 17 leaves method body",
});
expect(result.call_edges).toMatchObject([
{
opcode: "call",
target_token: "0x0a000001",
target_name: "ObservedCall",
target_declaring_type: "Fixture.Program",
},
]);
expect(result.field_accesses).toMatchObject([
{
opcode: "ldsfld",
field_token: "0x04000001",
field_name: "ObservedField",
field_declaring_type: "Fixture.Program",
},
]);
expect(result.user_strings).toMatchObject({
state: "partial",
literals: [{ token: literalToken, value: "observed" }],
issues: [],
});
expect(result.coverage.state).toBe("partial");
});
it("keeps a complete operand-bearing body decodable", () => {
expect(
fatBody(Buffer.from([0x00, 0x20, 0x01, 0x00, 0x00, 0x00, 0x2a])),
@@ -894,3 +954,82 @@ describe("managed exception section decoding", () => {
});
});
});
describe("managed catch fact retention", () => {
it.each([
{
name: "local TypeDef",
classToken: 0x0200_0001,
typeRefScopeRaw: 0,
classType: "Fixture.Program",
identityResolved: true,
},
{
name: "TypeRef with an unavailable AssemblyRef scope",
classToken: 0x0100_0001,
typeRefScopeRaw: (9 << 2) | 2,
classType: "System.Runtime.Versioning.TargetFrameworkAttribute",
identityResolved: false,
},
{
name: "TypeRef with a cyclic scope",
classToken: 0x0100_0001,
typeRefScopeRaw: (1 << 2) | 3,
classType: "System.Runtime.Versioning.TargetFrameworkAttribute",
identityResolved: false,
},
])(
"retains $name catch facts before a malformed later EH section",
({ classToken, typeRefScopeRaw, classType, identityResolved }) => {
const header = Buffer.alloc(12);
header.writeUInt16LE(0x300b, 0);
header.writeUInt16LE(8, 2);
header.writeUInt32LE(1, 4);
const first = Buffer.alloc(16);
first[0] = 0x81;
first[1] = first.length;
first.writeUInt32LE(classToken, 12);
const later = Buffer.alloc(17);
later[0] = 0x01;
later[1] = later.length;
const il = Buffer.from([0x2a]);
const bytes = buildManagedPeFixture({
typeRefScopeRaw,
ilBody: Buffer.concat([header, il, Buffer.alloc(3), first, later]),
});
const result = inspectManagedMembersBytes(
bytes,
managedPeFixtureTarget(bytes),
);
expect(result.methods[0]?.body).toMatchObject({
status: "malformed",
il_sha256: createHash("sha256").update(il).digest("hex"),
normalized_il_sha256: null,
decoded_instruction_count: 1,
anchors: [{ opcode: "ret", operand: null }],
exception_regions: [
{
flags: 0,
class_token: `0x${classToken.toString(16).padStart(8, "0")}`,
class_type: classType,
class_type_name_sha256: identityResolved
? expect.any(String)
: null,
class_type_identity_resolved: identityResolved,
},
],
issue: "Exception section size does not contain whole clauses",
});
expect(result.coverage.state).toBe("partial");
if (!identityResolved)
expect(result.coverage.issues).toContainEqual(
expect.objectContaining({
code: "invalid-row",
scope: "metadata.TypeRef:0x01000001",
}),
);
expect(result.user_strings?.state).toBe("complete");
},
);
});
@@ -79,8 +79,7 @@ export const validateExceptionRegionRanges = (
const boundaries = new Set([...instructionOffsets, ilSize]);
for (const region of parsed.regions) {
const issue = clauseIssue(region, ilSize, boundaries);
if (issue !== null)
return { status: "malformed", regions: parsed.regions, issue };
if (issue !== null) return { ...parsed, status: "malformed", issue };
}
return parsed;
};
+49 -39
View File
@@ -18,8 +18,11 @@ import { parseUserStrings } from "./ManagedUserStrings.js";
import {
readCompressed,
signature,
isParsedTokenInstruction,
type DecodedMethod,
type ManagedMethod,
} from "./ManagedMemberInspectorCore.js";
import { decodeInstructions } from "./ManagedMemberInstructionDecoder.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import { managedTypeReferences } from "./ManagedTypeReferences.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
@@ -35,6 +38,19 @@ import {
managedPeFixtureTarget,
} from "./ManagedPe.fixture.js";
/** Decode real token operands for capacity tests that do not need a PE method body. */
const decodedTokenInstructions = (
opcode: number,
tokens: readonly number[],
) => {
const il = Buffer.alloc(tokens.length * 5);
for (const [index, token] of tokens.entries()) {
il.writeUInt8(opcode, index * 5);
il.writeUInt32LE(token, index * 5 + 1);
}
return decodeInstructions(il).parsed.filter(isParsedTokenInstruction);
};
/** A tiny-header body that loads each `ldstr` token, discards it, and returns. */
const ldstrBody = (tokens: readonly string[]): Buffer => {
const il = Buffer.concat([
@@ -1329,16 +1345,14 @@ describe("managed user strings", () => {
const layout = { userStrings: null } as unknown as ManagedMetadataLayout;
// Leave 1 KiB of the 64 MiB budget: too little for 100 distinct tokens.
managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 1024, null);
const method = {
body: {
anchors: Array.from({ length: 100 }, (_, index) => ({
il_offset: index * 5,
opcode: "ldstr",
operand_kind: "string",
operand: `0x70${(index + 1).toString(16).padStart(6, "0")}`,
})),
},
} as unknown as ManagedMethod;
const method: DecodedMethod = {
method: {} as ManagedMethod,
tokenInstructions: decodedTokenInstructions(
0x72,
Array.from({ length: 100 }, (_, index) => 0x70000001 + index),
),
catchTokens: new Map(),
};
expect(() =>
parseUserStrings(Buffer.alloc(0), layout, {
@@ -1512,16 +1526,14 @@ describe("managed user string issues", () => {
} as unknown as ManagedMetadataLayout;
// Leave 24 KiB: enough for the failures, not for 512 bytes leaked each.
managedDecodeBudget(layout).reserve(64 * 1024 * 1024 - 24 * 1024, null);
const method = {
body: {
anchors: Array.from({ length: 64 }, (_, index) => ({
il_offset: index * 5,
opcode: "ldstr",
operand_kind: "string",
operand: `0x70${(1 + index * 2).toString(16).padStart(6, "0")}`,
})),
},
} as unknown as ManagedMethod;
const method: DecodedMethod = {
method: {} as ManagedMethod,
tokenInstructions: decodedTokenInstructions(
0x72,
Array.from({ length: 64 }, (_, index) => 0x70000001 + index * 2),
),
catchTokens: new Map(),
};
expect(
parseUserStrings(heap, layout, { methods: [method], unscannedMethods: 0 })
@@ -1801,23 +1813,25 @@ describe("managed member decoding budget", () => {
describe("managed derived edges", () => {
it("admits call edges before the derived graph exceeds the budget", () => {
const anchor = {
il_offset: 0,
opcode: "call",
operand_kind: "method",
operand: "0x0a000001",
};
const method = {
token: "0x06000001",
declaring_type: "T".repeat(2000),
name: "m",
body: { anchors: Array.from({ length: 16 }, () => anchor) },
} as unknown as ManagedMethod;
const decoded: DecodedMethod = {
method,
tokenInstructions: decodedTokenInstructions(
0x28,
Array(16).fill(0x0a000001),
),
catchTokens: new Map(),
};
const budget = new ManagedDecodeBudget();
// Exercise the same capacity boundary without constructing 64 MiB of edges.
budget.reserve(64 * 1024 * 1024 - 16 * 1024, null);
expect(() => edges([method], new Map(), budget)).toThrow(
expect(() => edges([decoded], new Map(), budget)).toThrow(
ManagedReaderFailure,
);
});
@@ -1839,22 +1853,18 @@ describe("managed derived caller names", () => {
token: "0x06000001",
declaring_type: declaringType,
name: "m",
body: {
anchors: [
{
il_offset: 0,
opcode: "call",
operand_kind: "method",
operand: "0x0a000001",
},
],
},
} as unknown as ManagedMethod;
const decoded: DecodedMethod = {
method,
tokenInstructions: decodedTokenInstructions(0x28, [0x0a000001]),
catchTokens: new Map(),
};
const budget = new ManagedDecodeBudget();
// Only the remaining capacity matters to refusal before interpolation.
budget.reserve(64 * 1024 * 1024 - 1024, null);
expect(() => edges([method], new Map(), budget)).toThrow(
expect(() => edges([decoded], new Map(), budget)).toThrow(
ManagedReaderFailure,
);
expect(built).toBe(false);
+31 -31
View File
@@ -319,38 +319,35 @@ export const inspectManagedMembersBytes = (
methodLimit,
});
appendManagedParseIssues(issues, fields.issues, methods.issues);
const resolvedMethods = methods.methods.map((method) => ({
const resolvedMethods = methods.methods.map(({ method, catchTokens }) => ({
...method,
body: {
...method.body,
exception_regions: method.body.exception_regions.map((region) => {
const classType = resolveTypeName(region.class_token);
const tokenTable =
region.class_token === null
? null
: Number.parseInt(region.class_token.slice(2, 4), 16);
const tokenRow =
region.class_token === null
? 0
: Number.parseInt(region.class_token.slice(4), 16);
const identityResolved =
region.class_token === null
? null
: classType !== null && tokenTable === 2
? declarationTypeName(2, tokenRow) !== null
: false;
return {
...region,
class_type: classType,
class_type_name_sha256:
region.class_token !== null &&
(tokenTable === 1 || tokenTable === 2)
? (declarationTypeName(tokenTable, tokenRow)?.toString("hex") ??
null)
: null,
class_type_identity_resolved: identityResolved,
};
}),
exception_regions: method.body.exception_regions.map(
(region, ordinal) => {
const reference = catchTokens.get(ordinal) ?? null;
const classType = resolveTypeName(reference);
const identityResolved =
reference === null
? null
: classType !== null && reference.table === 2
? declarationTypeName(2, reference.row) !== null
: false;
return {
...region,
class_type: classType,
class_type_name_sha256:
reference !== null &&
(reference.table === 1 || reference.table === 2)
? (declarationTypeName(
reference.table,
reference.row,
)?.toString("hex") ?? null)
: null,
class_type_identity_resolved: identityResolved,
};
},
),
},
}));
// #US literals sit beside the member inventory and carry their own
@@ -370,16 +367,19 @@ export const inspectManagedMembersBytes = (
);
appendManagedParseIssues(issues, targets.issues);
const incompleteMembers =
[...fields.fields, ...memberRefs.refs, ...methods.methods].some(
[...fields.fields, ...memberRefs.refs].some(
(member) => member.signature.parse_status !== "decoded",
) ||
resolvedMethods.some(
(method) => method.signature.parse_status !== "decoded",
) ||
resolvedMethods.some(
(method) =>
method.body.status === "partial" ||
method.body.status === "malformed",
);
const related = edges(
resolvedMethods,
methods.methods,
targets.members,
managedDecodeBudget(layout),
);
+20 -4
View File
@@ -28,6 +28,7 @@ import {
decodeMetadataCodedIndex,
metadataCodedIndexCoordinate,
type MetadataCodedIndexCoordinate,
type MetadataTokenFact,
metadataToken,
readMetadataString,
sha256Bytes,
@@ -92,19 +93,19 @@ export interface MemberCore {
export const memberTargetIdentity = (
name: string,
declaringToken: string | null,
declaringType: Pick<MetadataTokenFact, "table" | "row"> | null,
signature: ManagedSignature,
typeName: DeclarationTypeName,
): string | null => {
if (
declaringToken === null ||
declaringType === null ||
signature.resolved_sha256 === null ||
signature.parse_status !== "decoded"
)
return null;
const table = Number.parseInt(declaringToken.slice(2, 4), 16);
const { table, row } = declaringType;
if (table !== 1 && table !== 2) return null;
const owner = typeName(table, Number.parseInt(declaringToken.slice(4), 16));
const owner = typeName(table, row);
return owner === null
? null
: createHash("sha256")
@@ -120,6 +121,21 @@ export interface ParsedInstruction {
readonly opcode: string;
readonly operandKind: ManagedInstructionAnchor["operand_kind"];
readonly operand: string | null;
readonly reference: MetadataTokenFact | null;
}
export type ParsedTokenInstruction = ParsedInstruction & {
readonly reference: MetadataTokenFact;
};
export const isParsedTokenInstruction = (
instruction: ParsedInstruction,
): instruction is ParsedTokenInstruction => instruction.reference !== null;
export interface DecodedMethod {
readonly method: ManagedMethod;
readonly tokenInstructions: readonly ParsedTokenInstruction[];
readonly catchTokens: ReadonlyMap<number, MetadataTokenFact>;
}
const rowRange = (
+64 -32
View File
@@ -1,3 +1,7 @@
import {
metadataTokenFact,
type MetadataTokenFact,
} from "./ManagedMetadataHeaps.js";
import type { ManagedDecodeBudget } from "./ManagedDecodeBudget.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import type {
@@ -240,20 +244,14 @@ const OPCODES = new Map<
[0xfe1e, { name: "readonly.", operand: "none" }],
]);
const tokenKind = (token: string): ManagedInstructionAnchor["operand_kind"] => {
if (token.startsWith("0x06") || token.startsWith("0x2b")) return "method";
if (token.startsWith("0x04")) return "field";
if (token.startsWith("0x70")) return "string";
if (token.startsWith("0x11")) return "signature";
// MemberRef rows can name either fields or methods; the table alone cannot
// establish the kind. Callers with parsed metadata may supply that fact.
if (token.startsWith("0x0a")) return "token";
if (
token.startsWith("0x01") ||
token.startsWith("0x02") ||
token.startsWith("0x1b")
)
return "type";
const tokenKind = (table: number): ManagedInstructionAnchor["operand_kind"] => {
if (table === 6 || table === 0x2b) return "method";
if (table === 4) return "field";
if (table === 0x70) return "string";
if (table === 0x11) return "signature";
// MemberRefs need their signature to distinguish methods from fields.
if (table === 0x0a) return "token";
if (table === 1 || table === 2 || table === 0x1b) return "type";
return "token";
};
@@ -261,6 +259,7 @@ type OperandResult = {
readonly next: number;
readonly anchorKind: ManagedInstructionAnchor["operand_kind"];
readonly value: string | null;
readonly reference?: MetadataTokenFact;
};
type OperandReader = (il: Buffer, offset: number) => OperandResult;
@@ -425,21 +424,22 @@ const readOperand = (
}
if (!TOKEN_OPERAND_KINDS.has(kind) && kind !== "token")
return { next: offset, anchorKind: "unknown", value: null };
const raw = il.readUInt32LE(offset);
const token = `0x${raw.toString(16).padStart(8, "0")}`;
const reference = metadataTokenFact(il.readUInt32LE(offset));
const { token } = reference;
return {
next: offset + TOKEN_OPERAND_WIDTH,
anchorKind:
TOKEN_OPERAND_KINDS.get(kind) ??
(kind === "token" ? memberRefKind?.(token) : undefined) ??
tokenKind(token),
tokenKind(reference.table),
value: token,
reference,
};
};
export const decodeInstructions = (
il: Buffer,
admitInstruction?: (offset: number) => void,
admitInstruction?: (offset: number, hasReference: boolean) => void,
memberRefKind?: (token: string) => "field" | "method" | undefined,
): {
readonly parsed: readonly ParsedInstruction[];
@@ -476,15 +476,18 @@ export const decodeInstructions = (
issue = `Instruction ${descriptor.name} at IL offset ${String(start)} leaves method body`;
break;
}
// The extent is admitted; bound transient records before allocating token facts.
admitInstruction?.(
start,
TOKEN_OPERAND_KINDS.has(descriptor.operand) ||
descriptor.operand === "token",
);
const operand = readOperand(
il,
offset,
descriptor.operand,
memberRefKind,
);
// Charge each actual instruction before growing the decoded arrays.
// A multi-byte operand must not consume a full record per raw IL byte.
admitInstruction?.(start);
offset = operand.next;
if (PREFIX_OPCODES.has(descriptor.name)) {
prefixStart ??= start;
@@ -497,6 +500,7 @@ export const decodeInstructions = (
opcode: descriptor.name,
operandKind: operand.anchorKind,
operand: operand.value,
reference: operand.reference ?? null,
});
}
} catch (cause: unknown) {
@@ -517,6 +521,7 @@ export const parseExceptionRegions = (
budget?: ManagedDecodeBudget,
): ExceptionRegionParseResult => {
const regions: ManagedExceptionRegion[] = [];
const catchTokens = new Map<number, MetadataTokenFact>();
let sectionOffset = offset;
let hasMoreSections = true;
while (hasMoreSections) {
@@ -529,6 +534,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Exception section header leaves artifact",
regions,
catchTokens,
);
const kind = bytes.readUInt8(sectionOffset);
@@ -536,6 +542,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
`Unsupported method data section kind ${String(kind & 0x3f)}`,
regions,
catchTokens,
);
const fat = (kind & 0x40) !== 0;
const size = fat
@@ -552,6 +559,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Exception section size leaves artifact",
regions,
catchTokens,
);
let count: number;
let sectionSize = size;
@@ -560,6 +568,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Exception section size does not contain whole clauses",
regions,
catchTokens,
);
count = (size - 4) / clauseSize;
} else if ((size - 4) % clauseSize === 0) {
@@ -573,6 +582,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Exception section size does not contain whole clauses",
regions,
catchTokens,
);
}
if (
@@ -582,6 +592,7 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Exception section size leaves artifact",
regions,
catchTokens,
);
if (
!fat &&
@@ -590,45 +601,64 @@ export const parseExceptionRegions = (
return malformedExceptionRegions(
"Small exception section reserved bytes are nonzero",
regions,
catchTokens,
);
// Accumulates with the method IL bound; the body reserves what it retains.
budget?.admitTransient(count * 256, sectionOffset);
for (const region of readExceptionClauses(
bytes,
sectionOffset + 4,
count,
fat,
{ start: sectionOffset + 4, count, fat, budget },
catchTokens,
regions.length,
))
regions.push(region);
hasMoreSections = (kind & 0x80) !== 0;
sectionOffset += sectionSize;
}
return { status: "complete", regions };
return { status: "complete", regions, catchTokens };
};
export type ExceptionRegionParseResult =
| {
readonly status: "complete";
readonly regions: ManagedExceptionRegion[];
readonly catchTokens: ReadonlyMap<number, MetadataTokenFact>;
}
| {
readonly status: "malformed";
readonly regions: ManagedExceptionRegion[];
readonly catchTokens: ReadonlyMap<number, MetadataTokenFact>;
readonly issue: string;
};
const malformedExceptionRegions = (
issue: string,
regions: ManagedExceptionRegion[] = [],
): ExceptionRegionParseResult => ({ status: "malformed", regions, issue });
regions: ManagedExceptionRegion[],
catchTokens: ReadonlyMap<number, MetadataTokenFact>,
): ExceptionRegionParseResult => ({
status: "malformed",
regions,
catchTokens,
issue,
});
const readExceptionClauses = (
bytes: Buffer,
start: number,
count: number,
fat: boolean,
{
start,
count,
fat,
budget,
}: {
readonly start: number;
readonly count: number;
readonly fat: boolean;
readonly budget: ManagedDecodeBudget | undefined;
},
catchTokens: Map<number, MetadataTokenFact>,
firstOrdinal: number,
): ManagedExceptionRegion[] => {
const regions: ManagedExceptionRegion[] = [];
const clauseSize = fat ? 24 : 12;
@@ -636,6 +666,9 @@ const readExceptionClauses = (
const clause = start + index * clauseSize;
const flags = fat ? bytes.readUInt32LE(clause) : bytes.readUInt16LE(clause);
const extra = bytes.readUInt32LE(clause + (fat ? 20 : 8));
if (flags === 0) budget?.admitTransient(256, clause);
const reference = flags === 0 ? metadataTokenFact(extra) : null;
if (reference !== null) catchTokens.set(firstOrdinal + index, reference);
regions.push({
flags,
try_offset: fat
@@ -650,8 +683,7 @@ const readExceptionClauses = (
handler_length: fat
? bytes.readUInt32LE(clause + 16)
: bytes.readUInt8(clause + 7),
class_token:
flags === 0 ? `0x${extra.toString(16).padStart(8, "0")}` : null,
class_token: reference?.token ?? null,
class_type: null,
class_type_name_sha256: null,
class_type_identity_resolved: null,
+49 -29
View File
@@ -19,6 +19,7 @@ import {
type ManagedFieldAccess,
type ManagedMemberRef,
type ManagedMethod,
type DecodedMethod,
type MemberCore,
type SignatureTypeName,
type DeclarationTypeName,
@@ -26,6 +27,7 @@ import {
} from "./ManagedMemberInspectorCore.js";
import {
metadataToken,
type MetadataTokenFact,
metadataRowCursor,
decodeMetadataCodedIndex,
readMetadataBlob,
@@ -121,7 +123,7 @@ export const parseFields = (
kind: "field",
identitySha256: memberTargetIdentity(
name,
declared?.token ?? null,
declared === undefined ? null : { table: 2, row: owner },
decoded,
declarationTypeName,
),
@@ -215,7 +217,9 @@ export const parseMemberRefs = (
signatureTypeName,
declarationTypeName,
}: {
readonly typeName: (token: string | null) => string | null;
readonly typeName: (
reference: Pick<MetadataTokenFact, "table" | "row" | "token"> | null,
) => string | null;
readonly signatureTypeName: SignatureTypeName;
readonly declarationTypeName: DeclarationTypeName;
},
@@ -264,7 +268,7 @@ export const parseMemberRefs = (
: `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parent.reason}`,
});
const parentToken = parent.kind === "valid" ? parent.token : null;
const parentName = typeName(parentToken);
const parentName = typeName(parent.kind === "valid" ? parent : null);
const decoded = budgetedSignature(
sig,
layout,
@@ -288,7 +292,7 @@ export const parseMemberRefs = (
genericArity: signatureGenericArity(decoded),
identitySha256: memberTargetIdentity(
name,
parentToken,
parent.kind === "valid" ? parent : null,
decoded,
declarationTypeName,
),
@@ -325,7 +329,7 @@ export const parseMethods = ({
methodOffset = 0,
methodLimit = Number.MAX_SAFE_INTEGER,
}: ParseMethodsInput): {
readonly methods: readonly ManagedMethod[];
readonly methods: readonly DecodedMethod[];
readonly core: ReadonlyMap<string, MemberCore>;
readonly issues: readonly ManagedParseIssue[];
/**
@@ -334,7 +338,7 @@ export const parseMethods = ({
*/
readonly unscannedMethods: number;
} => {
const methods: ManagedMethod[] = [];
const methods: DecodedMethod[] = [];
const core = new Map<string, MemberCore>();
const issues: ManagedParseIssue[] = [];
let unscannedMethods = 0;
@@ -404,7 +408,9 @@ export const parseMethods = ({
genericArity: signatureGenericArity(decoded),
identitySha256: memberTargetIdentity(
name,
declared?.token ?? null,
declared === undefined || owner === undefined
? null
: { table: 2, row: owner },
decoded,
declarationTypeName,
),
@@ -418,17 +424,22 @@ export const parseMethods = ({
});
appendManagedParseIssues(issues, read.issues);
if (!read.scanned) unscannedMethods += 1;
managedDecodeBudget(layout).reserve(128, rowOffset);
methods.push({
token,
row_offset: rowOffset,
declaring_type_token: declared?.token ?? null,
declaring_type: declared?.fullName ?? null,
name,
rva,
impl_flags: implFlags,
flags,
signature: decoded,
body: read.body,
tokenInstructions: read.tokenInstructions,
catchTokens: read.catchTokens,
method: {
token,
row_offset: rowOffset,
declaring_type_token: declared?.token ?? null,
declaring_type: declared?.fullName ?? null,
name,
rva,
impl_flags: implFlags,
flags,
signature: decoded,
body: read.body,
},
});
}, issues);
if (mayBeSelected && methods.length === produced) unscannedMethods += 1;
@@ -445,12 +456,12 @@ const NAMEABLE_KINDS = new Map<string, ReadonlySet<MemberCore["kind"]>>([
["field", new Set(["field"])],
]);
const targetKind = (token: string): ManagedCallEdge["target_kind"] =>
token.startsWith("0x06")
const targetKind = (table: number): ManagedCallEdge["target_kind"] =>
table === 6
? "method-def"
: token.startsWith("0x0a")
: table === 0x0a
? "member-ref"
: token.startsWith("0x2b")
: table === 0x2b
? "method-spec"
: "unknown";
@@ -468,7 +479,7 @@ const namedTarget = (
};
export const edges = (
methods: readonly ManagedMethod[],
methods: readonly DecodedMethod[],
members: ReadonlyMap<string, MemberCore>,
budget: ManagedDecodeBudget,
): {
@@ -477,14 +488,23 @@ export const edges = (
} => {
const callEdges: ManagedCallEdge[] = [];
const fieldAccesses: ManagedFieldAccess[] = [];
for (const method of methods) {
const derives = method.body.anchors.some(
isManagedMemberReferenceInstruction,
for (const { method, tokenInstructions } of methods) {
const derives = tokenInstructions.some((instruction) =>
isManagedMemberReferenceInstruction({
opcode: instruction.opcode,
operand_kind: instruction.operandKind,
operand: instruction.operand,
}),
);
if (derives) budget.admitTransient(callerNameBytes(method), null);
// Built once per method, only after its exact size was admitted.
const caller = derives ? callerName(method) : method.name;
for (const anchor of method.body.anchors) {
for (const instruction of tokenInstructions) {
const anchor = {
opcode: instruction.opcode,
operand_kind: instruction.operandKind,
operand: instruction.operand,
};
if (!isManagedMemberReferenceInstruction(anchor)) continue;
const { operand } = anchor;
const target = namedTarget(members, anchor.operand_kind, operand);
@@ -494,12 +514,12 @@ export const edges = (
admitManagedProjection(
{
caller_token: method.token,
il_offset: anchor.il_offset,
il_offset: instruction.offset,
target_identity_sha256: target?.identitySha256 ?? null,
caller,
opcode: anchor.opcode,
target_token: operand,
target_kind: targetKind(operand),
target_kind: targetKind(instruction.reference.table),
target_name: target?.name ?? null,
target_declaring_type: target?.declaringType ?? null,
},
@@ -512,7 +532,7 @@ export const edges = (
admitManagedProjection(
{
method_token: method.token,
il_offset: anchor.il_offset,
il_offset: instruction.offset,
target_identity_sha256: target?.identitySha256 ?? null,
method: caller,
opcode: anchor.opcode,
+2 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
readMetadataBlob,
metadataTokenFact,
readMetadataString,
readMetadataUserString,
} from "./ManagedMetadataHeaps.js";
@@ -97,7 +98,7 @@ describe("managed metadata heaps", () => {
} as unknown as ManagedMetadataLayout;
expect(() =>
readMetadataUserString(bytes, layout, "0x70000001"),
readMetadataUserString(bytes, layout, metadataTokenFact(0x70000001)),
).toThrowError(
expect.objectContaining({
issue: expect.objectContaining({
+16 -2
View File
@@ -235,13 +235,14 @@ export const readMetadataBlob = (
export const readMetadataUserString = (
bytes: Buffer,
layout: ManagedMetadataLayout,
token: string,
reference: Pick<MetadataTokenFact, "token" | "row">,
): {
readonly offset: number;
readonly value: string;
/** Conformance differences observed beside the readable literal. */
readonly issues: readonly ManagedParseIssue[];
} => {
const { token, row: index } = reference;
const heap = layout.userStrings;
const scope = `metadata.#US:${token}`;
if (heap === null)
@@ -250,7 +251,6 @@ export const readMetadataUserString = (
scope,
"ldstr references #US, but the metadata has no #US stream",
);
const index = Number.parseInt(token.slice(4), 16);
if (index >= heap.size)
throw managedFailure(
"invalid-heap-index",
@@ -378,6 +378,20 @@ export const readMetadataGuid = (
export const metadataToken = (table: number, row: number): string =>
`0x${(table * 0x01_00_00_00 + row).toString(16).padStart(8, "0")}`;
/** Full-token coordinates do not establish that a table row or heap item exists. */
export interface MetadataTokenFact {
readonly raw: number;
readonly table: number;
readonly row: number;
readonly token: string;
}
export const metadataTokenFact = (raw: number): MetadataTokenFact => {
const table = raw >>> 24;
const row = raw & 0x00ff_ffff;
return { raw, table, row, token: metadataToken(table, row) };
};
/** Hash bytes without exposing their contents. */
export const sha256Bytes = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
+50 -13
View File
@@ -3,7 +3,10 @@ import {
type ManagedDecodeBudget,
} from "./ManagedDecodeBudget.js";
import type { ManagedPeLayout } from "./ManagedPeReader.js";
import type { ManagedMethodBody } from "./ManagedMemberInspectorCore.js";
import type {
ManagedMethodBody,
ParsedTokenInstruction,
} from "./ManagedMemberInspectorCore.js";
import {
decodeInstructions,
parseExceptionRegions,
@@ -11,14 +14,21 @@ import {
import { validateExceptionRegionRanges } from "./ManagedExceptionRegionValidation.js";
import {
metadataRowCursor,
metadataTokenFact,
type MetadataTokenFact,
readMetadataBlob,
sha256Bytes,
} from "./ManagedMetadataHeaps.js";
import { validateLocalSignature } from "./ManagedMemberInspectorCore.js";
import {
validateLocalSignature,
isParsedTokenInstruction,
} from "./ManagedMemberInspectorCore.js";
import { ManagedReaderFailure } from "./ManagedReaderFailure.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
const EMPTY_CATCH_TOKENS: ReadonlyMap<number, MetadataTokenFact> = new Map();
const bodyStatus = (
decodedIssue: string | null,
truncatedInstructions: number,
@@ -92,6 +102,8 @@ export const methodBody = (
context: MethodBodyContext,
): {
readonly body: ManagedMethodBody;
readonly tokenInstructions: readonly ParsedTokenInstruction[];
readonly catchTokens: ReadonlyMap<number, MetadataTokenFact>;
readonly issues: readonly ManagedParseIssue[];
/**
* Whether every CIL instruction was decoded, independent of exception-region
@@ -104,6 +116,8 @@ export const methodBody = (
if (rva === 0)
return {
body: emptyMethodBody(rva, "absent", null),
tokenInstructions: [],
catchTokens: EMPTY_CATCH_TOKENS,
scanned: true,
issues,
};
@@ -115,6 +129,8 @@ export const methodBody = (
"CIL decoding is unavailable for native, runtime, unmanaged, or P/Invoke implementations";
return {
body: emptyMethodBody(rva, "partial", issue),
tokenInstructions: [],
catchTokens: EMPTY_CATCH_TOKENS,
scanned: true,
issues,
};
@@ -137,7 +153,10 @@ export const methodBody = (
// separate instruction for every byte. Retained output is charged below.
const decoded = decodeInstructions(
il,
(start) => context.budget.admitTransient(256, ilOffset + start),
(start, hasReference) => {
context.budget.admitTransient(256, ilOffset + start);
if (hasReference) context.budget.admitTransient(256, ilOffset + start);
},
(token) => context.memberRefKinds.get(token),
);
const opcodeCounts: Record<string, number> = {};
@@ -179,12 +198,13 @@ export const methodBody = (
: null;
let localSigIssue: string | null = null;
let localSigFailure: "malformed" | "partial" | null = null;
if (header.localSig !== 0) {
const table = header.localSig >>> 24;
const row = header.localSig & 0x00ff_ffff;
const localSignature =
header.localSig === 0 ? null : metadataTokenFact(header.localSig);
if (localSignature !== null) {
const { table, row, token } = localSignature;
const rows = context.metadataLayout.table(17)?.rowCount ?? 0;
if (table !== 17 || row === 0 || row > rows) {
localSigIssue = `LocalVarSigTok 0x${header.localSig.toString(16).padStart(8, "0")} does not name an existing StandAloneSig row`;
localSigIssue = `LocalVarSigTok ${token} does not name an existing StandAloneSig row`;
localSigFailure = "malformed";
issues.push({
code: "invalid-row",
@@ -193,7 +213,7 @@ export const methodBody = (
detail: localSigIssue,
});
} else {
const signatureScope = `metadata.StandAloneSig:0x${header.localSig.toString(16).padStart(8, "0")}.signature`;
const signatureScope = `metadata.StandAloneSig:${token}.signature`;
try {
const cursor = metadataRowCursor(
bytes,
@@ -247,10 +267,7 @@ export const methodBody = (
file_offset: offset,
max_stack: header.maxStack,
init_locals: (header.flags & 0x10) !== 0,
local_var_sig_token:
header.localSig === 0
? null
: `0x${header.localSig.toString(16).padStart(8, "0")}`,
local_var_sig_token: localSignature?.token ?? null,
il_size: header.ilSize,
il_sha256: sha256Bytes(il),
normalized_il_sha256:
@@ -271,8 +288,26 @@ export const methodBody = (
? "Instruction decoding stopped before the end of the method body"
: null),
};
const admittedBody = admitManagedProjection(body, context.budget);
const tokenInstructions: ParsedTokenInstruction[] = [];
// These records outlive transient decoding; the public projection contains only strings.
for (const instruction of decoded.parsed) {
if (!isParsedTokenInstruction(instruction)) continue;
context.budget.reserve(
256 + (tokenInstructions.length === 0 ? 64 : 0),
ilOffset + instruction.offset,
);
tokenInstructions.push(instruction);
}
const catchTokens = exceptionRegions?.catchTokens.size
? exceptionRegions.catchTokens
: EMPTY_CATCH_TOKENS;
if (catchTokens.size > 0)
context.budget.reserve(64 + catchTokens.size * 256, sectionOffset);
return {
body: admitManagedProjection(body, context.budget),
body: admittedBody,
tokenInstructions,
catchTokens,
scanned: decoded.issue === null && decoded.truncated === 0,
issues,
};
@@ -293,6 +328,8 @@ export const methodBody = (
"malformed",
cause instanceof Error ? cause.message : "Method body parse failed",
),
tokenInstructions: [],
catchTokens: EMPTY_CATCH_TOKENS,
scanned: false,
issues,
};
+21 -21
View File
@@ -5,12 +5,13 @@ import {
methodGenericArity,
specSignatureFacts,
type SignatureTypeName,
type ManagedMethod,
type DecodedMethod,
type MemberCore,
} from "./ManagedMemberInspectorCore.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import {
decodeMetadataCodedIndex,
type MetadataTokenFact,
metadataRowCursor,
readMetadataBlob,
} from "./ManagedMetadataHeaps.js";
@@ -24,7 +25,7 @@ import { managedFailure, readManagedValue } from "./ManagedReaderFailure.js";
export const edgeTargets = (
bytes: Buffer,
layout: ManagedMetadataLayout,
methods: readonly ManagedMethod[],
methods: readonly DecodedMethod[],
cores: readonly ReadonlyMap<string, MemberCore>[],
signatureTypeName: SignatureTypeName,
): {
@@ -36,15 +37,15 @@ export const edgeTargets = (
const seen = new Set<string>();
// Generic arity by method token, so instantiations of one method decode it once.
const arities = new Map<string, number | null>();
for (const method of methods)
for (const { operand } of method.body.anchors) {
if (operand?.startsWith("0x2b") !== true || seen.has(operand)) continue;
seen.add(operand);
for (const { tokenInstructions } of methods)
for (const { reference } of tokenInstructions) {
if (reference.table !== 0x2b || seen.has(reference.token)) continue;
seen.add(reference.token);
readManagedValue(
() =>
readMethodSpec(
{ bytes, layout, members, issues, arities, signatureTypeName },
operand,
reference,
),
issues,
);
@@ -73,15 +74,11 @@ const readMethodSpec = (
readonly arities: Map<string, number | null>;
readonly signatureTypeName: SignatureTypeName;
},
token: string,
reference: MetadataTokenFact,
): void => {
const { token, row } = reference;
const scope = `metadata.MethodSpec:${token}`;
const cursor = metadataRowCursor(
bytes,
layout,
0x2b,
Number.parseInt(token.slice(4), 16),
);
const cursor = metadataRowCursor(bytes, layout, 0x2b, row);
const methodRaw = cursor.readIndex(layout.codedIndexSize("MethodDefOrRef"));
const instantiation = cursor.readIndex(layout.blobIndexSize);
const method = decodeMetadataCodedIndex(
@@ -100,8 +97,9 @@ const readMethodSpec = (
? "MethodSpec method coded index 0x0 is null, but the Method column must reference a row"
: `MethodSpec method coded index 0x${methodRaw.toString(16)} is invalid: ${method.reason}`,
});
const generic = method.kind === "valid" ? method.token : null;
const target = generic === null ? undefined : members.get(generic);
const methodReference = method.kind === "valid" ? method : null;
const target =
methodReference === null ? undefined : members.get(methodReference.token);
// A MethodSpec instantiates a method, so a field target never lends it a
// name, even to a field operand.
if (target !== undefined && target.kind !== "field")
@@ -113,13 +111,17 @@ const readMethodSpec = (
signatureTypeName,
);
const { types } = instantiationFacts;
if (generic === null) return;
if (methodReference === null) return;
const generic = methodReference.token;
// Prefer the arity the target's owner decoded; read the raw signature only
// for a target nothing decoded, such as a MethodDef outside the selection.
if (target?.genericArity === undefined && !arities.has(generic))
arities.set(
generic,
methodGenericArity(layout, methodSignatureBlob(bytes, layout, generic)),
methodGenericArity(
layout,
methodSignatureBlob(bytes, layout, methodReference),
),
);
const arity =
target?.genericArity === undefined
@@ -155,10 +157,8 @@ const readMethodSpec = (
const methodSignatureBlob = (
bytes: Buffer,
layout: ManagedMetadataLayout,
token: string,
{ table, row }: Pick<MetadataTokenFact, "table" | "row">,
): Buffer => {
const table = Number.parseInt(token.slice(2, 4), 16);
const row = Number.parseInt(token.slice(4), 16);
let signatureIndex: number;
if (table === 6) {
signatureIndex = readMethodDefinitionRow(bytes, layout, row).signatureIndex;
+7 -5
View File
@@ -11,6 +11,7 @@ import {
} from "./ManagedMetadataLayout.js";
import {
metadataRowCursor,
type MetadataTokenFact,
metadataToken,
readMetadataBlob,
} from "./ManagedMetadataHeaps.js";
@@ -29,7 +30,9 @@ export const typeNameLookup = (
ranges: readonly TypeRange[],
issues: ManagedParseIssue[],
references: ManagedTypeReferenceLookup,
): ((token: string | null) => string | null) => {
): ((
reference: Pick<MetadataTokenFact, "table" | "row" | "token"> | null,
) => string | null) => {
const names = new Map<string, string | null>();
const definitionName = (table: 1 | 2, row: number): string | null => {
if (table === 2) {
@@ -59,11 +62,10 @@ export const typeNameLookup = (
? null
: definitionName(nominalGenericType.table, nominalGenericType.row);
};
return (token) => {
if (token === null) return null;
return (reference) => {
if (reference === null) return null;
const { token, table, row } = reference;
if (names.has(token)) return names.get(token) ?? null;
const table = Number.parseInt(token.slice(2, 4), 16);
const row = Number.parseInt(token.slice(4), 16);
const name =
readManagedValue(
() =>
+24 -18
View File
@@ -7,11 +7,14 @@ import type {
ManagedParseIssue,
} from "../domain/managed/managedArtifact.js";
import type {
ManagedMethod,
DecodedMethod,
ManagedUserString,
} from "./ManagedMemberInspectorCore.js";
import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js";
import { readMetadataUserString } from "./ManagedMetadataHeaps.js";
import {
readMetadataUserString,
type MetadataTokenFact,
} from "./ManagedMetadataHeaps.js";
import { readManagedValue } from "./ManagedReaderFailure.js";
type UserStringInventory = NonNullable<ManagedMemberInspection["user_strings"]>;
@@ -40,7 +43,7 @@ export const parseUserStrings = (
bytes: Buffer,
layout: ManagedMetadataLayout,
parsed: {
readonly methods: readonly ManagedMethod[];
readonly methods: readonly DecodedMethod[];
/** Methods that may be in scope but whose CIL was not fully scanned. */
readonly unscannedMethods: number;
/** Unreadable selected type names may hide a requested type. */
@@ -54,27 +57,27 @@ export const parseUserStrings = (
(parsed.unreadableSelectedTypes ?? 0) === 0;
const userStrings: ManagedUserString[] = [];
const issues: ManagedParseIssue[] = [];
const tokens = new Set<string>();
const tokens = new Map<number, MetadataTokenFact>();
try {
// Only ldstr loads a literal; a malformed ldtoken can carry a 0x70 token.
for (const method of methods)
for (const { opcode, operand } of method.body.anchors)
if (opcode === "ldstr" && operand !== null && !tokens.has(operand)) {
for (const { tokenInstructions } of methods)
for (const { opcode, reference } of tokenInstructions)
if (opcode === "ldstr" && !tokens.has(reference.raw)) {
budget.admitTransient(USER_STRING_TOKEN_BYTES, null);
tokens.add(operand);
tokens.set(reference.raw, reference);
}
// Tokens are fixed-width lowercase hex, so string order is token order.
const references: string[] = [];
for (const token of [...tokens].sort()) {
if (token.startsWith("0x70")) references.push(token);
// An operand from another table names no #US item, with or without
// the stream.
// Unsigned token order is the same as their fixed-width lowercase hex order.
const references: MetadataTokenFact[] = [];
for (const reference of [...tokens.values()].sort(
(left, right) => left.raw - right.raw,
)) {
if (reference.table === 0x70) references.push(reference);
else
issues.push(
admitManagedProjection(
{
code: "invalid-heap-index",
scope: `metadata.#US:${token}`,
scope: `metadata.#US:${reference.token}`,
offset: null,
detail: "ldstr operand does not name an item inside #US",
},
@@ -97,17 +100,20 @@ export const parseUserStrings = (
);
return inventory(userStrings, issues, scanned);
}
for (const token of references) {
for (const reference of references) {
const failures: ManagedParseIssue[] = [];
readManagedValue(() => {
const {
offset,
value,
issues: observed,
} = readMetadataUserString(bytes, layout, token);
} = readMetadataUserString(bytes, layout, reference);
for (const issue of observed) failures.push(issue);
userStrings.push(
admitManagedProjection({ token, offset, value }, budget),
admitManagedProjection(
{ token: reference.token, offset, value },
budget,
),
);
}, failures);
for (const failure of failures)
+80
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -53,6 +54,85 @@ cliTest(
},
);
cliTest(
"managed CLI inspects malformed MethodSpecs used by isolated non-call instructions",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-managed-methodspec-operands-",
);
for (const [opcode, name, operandKind] of [
[0x71, "ldobj", "type"],
[0x7e, "ldsfld", "field"],
[0xd0, "ldtoken", "method"],
] as const) {
const path = join(root, `${name}.dll`);
// Each body has one token operand; no call can trigger MethodSpec
// inspection on behalf of the non-call instruction under observation.
const il = Buffer.from([opcode, 1, 0, 0, 0x2b, 0x2a]);
await writeFile(
path,
buildManagedPeFixture({
memberRefName: "NotAMethod",
memberRefSignature: Buffer.from([0x06, 0x0e]),
methodSpecMethods: [(1 << 1) | 1],
ilBody: Buffer.concat([Buffer.from([(il.length << 2) | 2]), il]),
}),
);
const result = await cli.run({
arguments: ["inspect-managed-members", path, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
operation: "inspect_managed_members",
subject: { local_path: path },
normalized_result: {
methods: [
expect.objectContaining({
body: expect.objectContaining({
status: "present",
il_sha256: createHash("sha256").update(il).digest("hex"),
normalized_il_sha256: expect.any(String),
anchors: [
expect.objectContaining({
opcode: name,
operand_kind: operandKind,
operand: "0x2b000001",
}),
expect.objectContaining({ opcode: "ret", operand: null }),
],
issue: null,
}),
}),
],
call_edges: [],
field_accesses:
name === "ldsfld"
? [
expect.objectContaining({
opcode: "ldsfld",
field_token: "0x2b000001",
field_name: null,
field_declaring_type: null,
}),
]
: [],
coverage: {
state: "partial",
issues: expect.arrayContaining([
expect.objectContaining({
code: "invalid-row",
scope: "metadata.MethodSpec:0x2b000001",
detail:
"MethodSpec method does not have a decodable method signature",
}),
]),
},
},
});
}
},
);
cliTest(
"managed CLI retains metadata and CIL after repeated local constructor owner lookups",
async ({ cli }) => {
@@ -775,6 +775,25 @@ describe("managed comparison post-admission read failures", () => {
});
describe("managed comparison snapshot admission and cleanup", () => {
const snapshotReaderWithClose =
(
closeHandle: (owner: OwnedFileHandle) => Promise<void>,
): typeof readManagedSnapshot =>
async (path, operation, options) => {
// Target resolution owns separate handles. Inject only while the real
// snapshot reader owns the descriptor whose retry this test observes.
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
return closeHandle(this);
});
try {
return await readManagedSnapshot(path, operation, options);
} finally {
close.mockRestore();
}
};
it("rejects a sparse oversized snapshot through the default reader before reading it", async () => {
const directory = await createTestTempDirectory(
"rea-managed-oversized-snapshot-",
@@ -825,26 +844,28 @@ describe("managed comparison snapshot admission and cleanup", () => {
const originalClose = OwnedFileHandle.prototype.close;
const owners: OwnedFileHandle[] = [];
let attempts = 0;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
owners.push(this);
attempts += 1;
if (attempts === 1) throw new Error("injected transient close failure");
await originalClose.call(this);
});
try {
const result = await compareManagedMemberPaths(
{ leftPath, rightPath },
{},
{
readSnapshot: snapshotReaderWithClose(async (owner) => {
owners.push(owner);
attempts += 1;
if (attempts === 1)
throw new Error("injected transient close failure");
await originalClose.call(owner);
}),
},
resources,
);
expect(result.ok).toBe(true);
expect(owners[0]).toBe(owners[1]);
expect(attempts).toBe(3);
expect(owners[2]).not.toBe(owners[0]);
expect(owners.map((owner) => owner.handle.fd)).toEqual([-1, -1, -1]);
expect(resources.hasPending).toBe(false);
} finally {
close.mockRestore();
await resources.close();
}
});
@@ -860,17 +881,16 @@ describe("managed comparison snapshot admission and cleanup", () => {
const resources = new ArtifactResourceScope();
const cleanupCause = new Error("injected persistent close failure");
const owners: OwnedFileHandle[] = [];
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
owners.push(this);
throw cleanupCause;
});
let result: Awaited<ReturnType<typeof compareManagedMemberPaths>>;
try {
result = await compareManagedMemberPaths(
{ leftPath, rightPath },
{},
{
readSnapshot: snapshotReaderWithClose(async (owner) => {
owners.push(owner);
throw cleanupCause;
}),
},
resources,
);
expect(result.ok).toBe(false);
@@ -879,12 +899,16 @@ describe("managed comparison snapshot admission and cleanup", () => {
code: "cleanup_incomplete",
details: { cleanup: "incomplete", resources: [leftPath] },
});
expect(owners).toHaveLength(2);
expect(owners[0]).toBe(owners[1]);
expect(owners[0]?.handle.fd).toBeGreaterThanOrEqual(0);
expect(resources.hasPending).toBe(true);
expect(resources.cleanupResources).toEqual([leftPath]);
} finally {
close.mockRestore();
await resources.close();
}
expect(owners[0]?.handle.fd).toBe(-1);
expect(resources.hasPending).toBe(false);
});
});