diff --git a/src/dotnet/ManagedArtifactFailures.test.ts b/src/dotnet/ManagedArtifactFailures.test.ts index b80e14463..1647157b5 100644 --- a/src/dotnet/ManagedArtifactFailures.test.ts +++ b/src/dotnet/ManagedArtifactFailures.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; +import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; +import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js"; import { buildManagedPeFixture, buildNativePeFixture, @@ -43,4 +45,37 @@ describe("managed artifact failure classification", () => { expect.objectContaining({ code: "invalid-tables" }), ]); }); + + it("keeps malformed reference and attribute issues across inventory callers", () => { + const bytes = buildManagedPeFixture({ + references: [ + "System.Runtime", + "System.Runtime", + "UnityEngine.CoreModule", + ], + malformedAssemblyReferenceRows: [3], + malformedCustomAttributeRows: [1], + }); + const target = managedPeFixtureTarget(bytes); + const artifact = inspectManagedArtifactBytes(bytes, target); + const members = inspectManagedMembersBytes(bytes, target); + const boundaries = inspectManagedNativeBoundariesBytes(bytes, target); + const issueCodes = ["invalid-heap-index", "invalid-row"]; + + expect(artifact.references.map(({ name }) => name)).toEqual([ + "System.Runtime", + "System.Runtime", + ]); + expect(artifact.coverage.issues.map(({ code }) => code)).toEqual( + issueCodes, + ); + expect(members.coverage.issues.map(({ code }) => code)).toEqual(issueCodes); + expect(boundaries.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ code: "invalid-heap-index" }), + expect.objectContaining({ code: "invalid-row" }), + ], + }); + }); }); diff --git a/src/dotnet/ManagedArtifactInspector.test.ts b/src/dotnet/ManagedArtifactInspector.test.ts index e98e7aeaf..638dfd353 100644 --- a/src/dotnet/ManagedArtifactInspector.test.ts +++ b/src/dotnet/ManagedArtifactInspector.test.ts @@ -9,8 +9,66 @@ import { buildManagedPeFixture, managedPeFixtureTarget, } from "./ManagedPe.fixture.js"; +import { readManagedMetadataLayout } from "./ManagedMetadataLayout.js"; +import { readManagedPeLayout } from "./ManagedPeReader.js"; + +const metadataLayoutFor = (bytes: Buffer) => { + const pe = readManagedPeLayout(bytes); + if (pe.cli === null) throw new Error("fixture omitted CLI metadata"); + return readManagedMetadataLayout( + bytes, + pe.rvaToOffset(pe.cli.metadata.rva, pe.cli.metadata.size, "cli.metadata"), + pe.cli.metadata.size, + ); +}; describe("managed artifact inventory", () => { + it.each([ + { moduleRowCount: 0, assemblyRowCount: 1, scope: "metadata.Module" }, + { moduleRowCount: 2, assemblyRowCount: 1, scope: "metadata.Module" }, + { moduleRowCount: 1, assemblyRowCount: 2, scope: "metadata.Assembly" }, + ])("marks invalid identity table cardinality partial: %j", (options) => { + const bytes = buildManagedPeFixture(options); + const target = managedPeFixtureTarget(bytes); + const results = [ + inspectManagedArtifactBytes(bytes, target), + inspectManagedMembersBytes(bytes, target), + inspectManagedNativeBoundariesBytes(bytes, target), + ]; + + for (const result of results) { + expect(result.metadata.status).toBe("partial"); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + scope: options.scope, + }), + ], + }); + } + if (options.moduleRowCount === 0) expect(results[0]?.module).toBeNull(); + else expect(results[0]?.module?.name).toBe("Fixture.dll"); + }); + + it.each([ + { moduleRowCount: 1, assemblyRowCount: 0, targetFramework: null }, + { moduleRowCount: 1, assemblyRowCount: 1 }, + ])("keeps valid identity table cardinalities complete: %j", (options) => { + const bytes = buildManagedPeFixture(options); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.metadata.status).toBe("complete"); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + expect(result.assembly?.name ?? null).toBe( + options.assemblyRowCount === 0 ? null : "Fixture.Managed", + ); + }); + it("statically inspects PE files with more than 96 sections", () => { const fixture = buildManagedPeFixture(); const peOffset = fixture.readUInt32LE(0x3c); @@ -77,7 +135,133 @@ describe("managed artifact inventory", () => { ]); expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); }); +}); +describe("managed artifact coded indexes", () => { + it("reports an invalid ManifestResource implementation while retaining the resource", () => { + const bytes = buildManagedPeFixture({ resourceImplementationRaw: 9 }); + const target = managedPeFixtureTarget(bytes); + const result = inspectManagedArtifactBytes(bytes, target); + const boundaries = inspectManagedNativeBoundariesBytes(bytes, target); + + expect(result.resources).toMatchObject([ + { + name: "Fixture.resources", + implementation_token: null, + embedded: false, + }, + ]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.ManifestResource:0x28000001", + detail: expect.stringContaining( + "coded index 0x9 is invalid: coded index selects row 2 in table 35, which has 1 rows", + ), + }), + ], + }); + const layout = metadataLayoutFor(bytes); + expect(result.coverage.issues[0]?.offset).toBe( + (result.resources[0]?.row_offset ?? 0) + 8 + layout.stringIndexSize, + ); + expect(boundaries.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + scope: "metadata.ManifestResource:0x28000001", + }), + ], + }); + }); + + it("reports an invalid CustomAttribute constructor coded index", () => { + const bytes = buildManagedPeFixture({ customAttributeTypeRaw: 19 }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.attributes).toEqual([]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.CustomAttribute:0x0c000001", + detail: expect.stringContaining( + "coded index 0x13 is invalid: coded index selects row 2 in table 10, which has 1 rows", + ), + }), + ], + }); + }); + + it("reports null for the required CustomAttribute constructor coded index", () => { + const bytes = buildManagedPeFixture({ customAttributeTypeRaw: 0 }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.attributes).toEqual([]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + detail: + "CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row", + }), + ], + }); + }); + + it("reports coded-index column offsets after widened string and coded-index columns", () => { + const references = Array.from( + { length: 2_500 }, + (_, index) => `Reference.${"r".repeat(24)}.${String(index)}`, + ); + const bytes = buildManagedPeFixture({ + references, + extendsRaw: 9, + customAttributeTypeRaw: 19, + }); + const layout = metadataLayoutFor(bytes); + const artifact = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + const members = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + const extendsIssue = members.coverage.issues.find((issue) => + issue.scope.startsWith("metadata.TypeDef:"), + ); + const attributeIssue = artifact.coverage.issues.find((issue) => + issue.scope.startsWith("metadata.CustomAttribute:"), + ); + const typeDef = members.types[0]; + const attributeTable = layout.table(12); + + expect(layout.stringIndexSize).toBe(4); + expect(layout.codedIndexSize("HasCustomAttribute")).toBe(4); + expect(typeDef).toBeDefined(); + expect(extendsIssue?.offset).toBe( + (typeDef?.row_offset ?? 0) + 4 + 2 * layout.stringIndexSize, + ); + expect(attributeTable).toBeDefined(); + expect(attributeIssue?.offset).toBe( + (attributeTable?.offset ?? 0) + + layout.codedIndexSize("HasCustomAttribute"), + ); + }); +}); + +describe("managed artifact reference inventory", () => { it("returns all references inline while classifying from the full inventory", () => { const bytes = buildManagedPeFixture({ references: ["System.Runtime", "UnityEngine.CoreModule"], @@ -93,6 +277,60 @@ describe("managed artifact inventory", () => { expect(result.coverage.issues).toEqual([]); }); + it("preserves duplicate AssemblyRef rows in metadata order", () => { + const bytes = buildManagedPeFixture({ + references: [ + "UnityEngine.CoreModule", + "UnityEngine.CoreModule", + "System.Runtime", + ], + }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.references.map(({ name }) => name)).toEqual([ + "UnityEngine.CoreModule", + "UnityEngine.CoreModule", + "System.Runtime", + ]); + expect(result.classification.runtime_family).toBe("unity-mono"); + }); +}); + +describe("managed fixture data layout", () => { + it("keeps ReadyToRun metadata and resources intact with large metadata", () => { + const resource = Buffer.alloc(2_048, 0x52); + const fieldSignature = Buffer.concat([ + Buffer.from([0x06]), + Buffer.alloc(8_000, 0x0f), + Buffer.from([0x08]), + ]); + const bytes = buildManagedPeFixture({ + fieldSignature, + readyToRun: true, + resourceData: resource, + }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.classification).toMatchObject({ + status: "managed", + implementation: "cil-and-ready-to-run", + }); + expect(result.resources[0]).toMatchObject({ + embedded: true, + data_length: resource.length, + data_sha256: createHash("sha256").update(resource).digest("hex"), + }); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + }); +}); + +describe("managed artifact metadata identity", () => { it("accepts CLI metadata GUIDs without RFC UUID version or variant bits", () => { const bytes = buildManagedPeFixture({ mvid: Buffer.from("3aebc60edc4a544b1f458b4ed40b33b1", "hex"), @@ -140,6 +378,55 @@ describe("managed artifact inventory", () => { }); }); +it("derives the string heap width after expanded AssemblyRef names", () => { + const references = Array.from( + { length: 5_000 }, + (_, index) => `Reference.${String(index).padStart(4, "0")}`, + ); + const bytes = buildManagedPeFixture({ references }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.references).toHaveLength(references.length); + expect(result.references.at(-1)?.name).toBe("Reference.4999"); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + + const malformedBytes = buildManagedPeFixture({ + references, + malformedAssemblyReferenceRows: [5_000], + }); + const malformedResult = inspectManagedArtifactBytes( + malformedBytes, + managedPeFixtureTarget(malformedBytes), + ); + expect(malformedResult.references).toHaveLength(4_999); + expect(malformedResult.coverage).toMatchObject({ + state: "partial", + issues: [expect.objectContaining({ code: "invalid-heap-index" })], + }); +}); + +it("derives ResolutionScope and Implementation widths at 16,384 AssemblyRefs", () => { + const references = Array.from( + { length: 16_384 }, + (_, index) => `Reference.${String(index).padStart(5, "0")}`, + ); + const bytes = buildManagedPeFixture({ references }); + const result = inspectManagedArtifactBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.references).toHaveLength(references.length); + expect(result.references.at(-1)?.name).toBe("Reference.16383"); + expect(result.resources).toMatchObject([ + { name: "Fixture.resources", embedded: true, data_length: 13 }, + ]); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); +}); + it("preserves U+FEFF in references and fixed attribute strings", () => { const bytes = buildManagedPeFixture({ references: ["\uFEFFSystem.Runtime", "System.\uFEFFRuntime"], diff --git a/src/dotnet/ManagedCilDecoding.test.ts b/src/dotnet/ManagedCilDecoding.test.ts index d143d7491..c1ed21130 100644 --- a/src/dotnet/ManagedCilDecoding.test.ts +++ b/src/dotnet/ManagedCilDecoding.test.ts @@ -25,6 +25,48 @@ const managedBodyWithSections = (...sections: Buffer[]) => { .methods[0]?.body; }; +const managedBodyWithRegion = ({ + il, + flags = 0, + tryOffset = 0, + tryLength = 1, + handlerOffset, + handlerLength = 1, + extra = 0x0200_0001, +}: { + readonly il: Buffer; + readonly flags?: number; + readonly tryOffset?: number; + readonly tryLength?: number; + readonly handlerOffset: number; + readonly handlerLength?: number; + readonly extra?: number; +}) => { + const header = Buffer.alloc(12); + header.writeUInt16LE(0x300b, 0); + header.writeUInt16LE(8, 2); + header.writeUInt32LE(il.length, 4); + const section = Buffer.alloc(16); + section[0] = 0x01; + section[1] = section.length; + section.writeUInt16LE(flags, 4); + section.writeUInt16LE(tryOffset, 6); + section[8] = tryLength; + section.writeUInt16LE(handlerOffset, 9); + section[11] = handlerLength; + section.writeUInt32LE(extra, 12); + const bytes = buildManagedPeFixture({ + ilBody: Buffer.concat([ + header, + il, + Buffer.alloc((4 - (il.length % 4)) % 4), + section, + ]), + }); + return inspectManagedMembersBytes(bytes, managedPeFixtureTarget(bytes)) + .methods[0]?.body; +}; + describe("managed CIL decoding", () => { it("reads fat method header size from the full flags-and-size word", () => { const il = Buffer.from([ @@ -79,6 +121,60 @@ describe("managed CIL decoding", () => { }); }); + it("does not decode overlay bytes after the method's file-backed PE section", () => { + const original = buildManagedPeFixture(); + const bytes = Buffer.concat([original, Buffer.from([0x2a])]); + const parsed = inspectManagedMembersBytes( + original, + managedPeFixtureTarget(original), + ); + const methodRow = parsed.methods[0]?.row_offset; + expect(methodRow).toBeDefined(); + if (methodRow === undefined) return; + bytes.writeUInt32LE(0x2dff, methodRow); + bytes[0xfff] = 0x06; + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.methods[0]?.body).toMatchObject({ + status: "malformed", + header_format: "unknown", + issue: "Method body leaves file-backed PE section data", + anchors: [], + }); + }); + + it("does not decode overlay exception sections after the file-backed PE section", () => { + const original = buildManagedPeFixture(); + const overlay = Buffer.from([0x01, 0x04, 0x00, 0x00]); + const bytes = Buffer.concat([original, overlay]); + const parsed = inspectManagedMembersBytes( + original, + managedPeFixtureTarget(original), + ); + const methodRow = parsed.methods[0]?.row_offset; + expect(methodRow).toBeDefined(); + if (methodRow === undefined) return; + const body = Buffer.alloc(16); + body.writeUInt16LE(0x301b, 0); + body.writeUInt16LE(8, 2); + body.writeUInt32LE(1, 4); + body[12] = 0x2a; + body.copy(bytes, 0xff0); + bytes.writeUInt32LE(0x2df0, methodRow); + overlay.copy(bytes, 0x1000); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + expect(result.methods[0]?.body).toMatchObject({ + status: "malformed", + issue: "Exception section header leaves artifact", + exception_regions: [], + }); + }); + it("keeps the documented decoded-CIL v1 golden vector stable", () => { const il = Buffer.from([0x00, 0x2a]); const tinyBytes = buildManagedPeFixture({ @@ -125,6 +221,218 @@ describe("managed CIL decoding", () => { }); }); +describe("managed exception region bounds", () => { + it("marks out-of-method ranges malformed and preserves the raw EH clause", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x2a]), + tryOffset: 0, + tryLength: 2, + handlerOffset: 0, + }), + ).toMatchObject({ + status: "malformed", + il_size: 1, + exception_regions: [ + { + flags: 0, + try_offset: 0, + try_length: 2, + handler_offset: 0, + handler_length: 1, + class_token: "0x02000001", + }, + ], + issue: "Exception clause try range leaves the method IL body", + }); + }); + + it("uses half-open method bounds and permits zero-length ranges", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x00, 0x00, 0x2a]), + tryOffset: 0, + tryLength: 1, + handlerOffset: 1, + handlerLength: 2, + }), + ).toMatchObject({ status: "present", il_size: 3, issue: null }); + expect( + managedBodyWithRegion({ + il: Buffer.from([0x2a]), + tryOffset: 0, + tryLength: 0, + handlerOffset: 0, + handlerLength: 0, + }), + ).toMatchObject({ status: "present", il_size: 1, issue: null }); + }); + + it.each([ + ["try start", { tryOffset: 1, tryLength: 4, handlerOffset: 5 }], + ["try end", { tryOffset: 0, tryLength: 1, handlerOffset: 5 }], + ["handler start", { tryLength: 5, handlerOffset: 1, handlerLength: 5 }], + ["handler end", { tryLength: 5, handlerOffset: 0, handlerLength: 1 }], + ] as const)( + "preserves clauses with a mid-instruction %s", + (_label, region) => { + const body = managedBodyWithRegion({ + il: Buffer.from([0x20, 0, 0, 0, 0, 0x2a]), + ...region, + }); + expect(body).toMatchObject({ + status: "malformed", + exception_regions: [expect.any(Object)], + issue: + "Exception clause " + + (_label.startsWith("try") ? "try" : "handler") + + " range does not align with CIL instruction boundaries", + }); + }, + ); + + it("rejects a filter start inside an instruction and retains the clause", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x20, 0, 0, 0, 0, 0x2a]), + flags: 1, + tryLength: 5, + handlerOffset: 5, + extra: 1, + }), + ).toMatchObject({ + status: "malformed", + exception_regions: [expect.objectContaining({ filter_offset: 1 })], + issue: + "Exception clause filter range does not align with CIL instruction boundaries", + }); + }); +}); + +describe("managed exception region prefix boundaries", () => { + it("treats a CIL prefix chain and its opcode as one boundary", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0xfe, 0x12, 1, 0xfe, 0x13, 0x4a, 0x2a]), + tryOffset: 0, + tryLength: 6, + handlerOffset: 6, + }), + ).toMatchObject({ status: "present", issue: null, il_size: 7 }); + }); + + it("keeps constrained callvirt as a single region boundary", () => { + const il = Buffer.from([0xfe, 0x16, 1, 0, 0, 1, 0x6f, 1, 0, 0, 6, 0x2a]); + expect( + managedBodyWithRegion({ + il, + tryOffset: 0, + tryLength: 11, + handlerOffset: 11, + }), + ).toMatchObject({ status: "present", issue: null, il_size: 12 }); + expect( + managedBodyWithRegion({ + il, + tryOffset: 6, + tryLength: 1, + handlerOffset: 11, + }), + ).toMatchObject({ + status: "malformed", + issue: + "Exception clause try range does not align with CIL instruction boundaries", + }); + }); + + it.each([ + ["start", { tryOffset: 5, tryLength: 1, handlerOffset: 6 }], + [ + "end after first prefix", + { tryOffset: 0, tryLength: 3, handlerOffset: 6 }, + ], + [ + "end after second prefix", + { tryOffset: 0, tryLength: 5, handlerOffset: 6 }, + ], + ] as const)( + "rejects a region %s between a prefix and its opcode", + (_edge, region) => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0xfe, 0x12, 1, 0xfe, 0x13, 0x4a, 0x2a]), + ...region, + }), + ).toMatchObject({ + status: "malformed", + exception_regions: [expect.any(Object)], + issue: + "Exception clause try range does not align with CIL instruction boundaries", + }); + }, + ); +}); + +describe("managed exception region flags and filters", () => { + it("marks reserved clause flags malformed without dropping its bytes", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x00, 0x2a]), + flags: 8, + tryLength: 1, + handlerOffset: 1, + }), + ).toMatchObject({ + status: "malformed", + exception_regions: [expect.objectContaining({ flags: 8 })], + issue: "Exception clause has unsupported flags 0x8", + }); + }); + + it.each([2, 4])( + "accepts the defined finally/fault clause flag %s", + (flags) => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x00, 0x00, 0x2a]), + flags, + tryOffset: 0, + tryLength: 1, + handlerOffset: 1, + handlerLength: 2, + }), + ).toMatchObject({ status: "present", issue: null }); + }, + ); + + it("validates filter ordering", () => { + expect( + managedBodyWithRegion({ + il: Buffer.from([0x00, 0x00, 0xfe, 0x11, 0x2a]), + flags: 1, + tryOffset: 0, + tryLength: 1, + handlerOffset: 4, + extra: 1, + }), + ).toMatchObject({ status: "present", il_size: 5, issue: null }); + expect( + managedBodyWithRegion({ + il: Buffer.from([0x00, 0x00, 0x2a]), + flags: 1, + tryOffset: 0, + tryLength: 1, + handlerOffset: 1, + extra: 1, + }), + ).toMatchObject({ + status: "malformed", + issue: + "Exception clause filter must start before its handler within the method IL body", + }); + }); +}); + describe("managed exception section decoding", () => { it("marks an exception section with a partial clause as malformed", () => { const section = Buffer.alloc(17); @@ -155,18 +463,34 @@ describe("managed exception section decoding", () => { }); }); - it("reports malformed data in a chained section", () => { + it("preserves decoded clauses when a chained section has an unsupported kind", () => { const first = Buffer.alloc(16); first[0] = 0x81; first[1] = first.length; const second = Buffer.from([0x02, 0x04, 0x00, 0x00]); expect(managedBodyWithSections(first, second)).toMatchObject({ status: "malformed", - exception_regions: [], + exception_regions: [expect.objectContaining({ flags: 0 })], issue: "Unsupported method data section kind 2", }); }); + it("preserves decoded clauses when a later section has a partial clause", () => { + const first = Buffer.alloc(16); + first[0] = 0x81; + first[1] = first.length; + first.writeUInt16LE(2, 4); + const later = Buffer.alloc(17); + later[0] = 0x01; + later[1] = later.length; + + expect(managedBodyWithSections(first, later)).toMatchObject({ + status: "malformed", + exception_regions: [expect.objectContaining({ flags: 2 })], + issue: "Exception section size does not contain whole clauses", + }); + }); + it.each([ { name: "unsupported section kind", diff --git a/src/dotnet/ManagedExceptionRegionValidation.ts b/src/dotnet/ManagedExceptionRegionValidation.ts new file mode 100644 index 000000000..e535c84f5 --- /dev/null +++ b/src/dotnet/ManagedExceptionRegionValidation.ts @@ -0,0 +1,86 @@ +import type { ManagedExceptionRegion } from "./ManagedMemberInspectorCore.js"; +import type { ExceptionRegionParseResult } from "./ManagedMemberInstructionDecoder.js"; + +const clauseFlagsIssue = (region: ManagedExceptionRegion): string | null => + [0, 1, 2, 4].includes(region.flags) + ? null + : `Exception clause has unsupported flags 0x${region.flags.toString(16)}`; + +const methodExtentIssue = ( + start: number, + length: number, + ilSize: number, + name: "try" | "handler", +): string | null => + start <= ilSize && length <= ilSize - start + ? null + : `Exception clause ${name} range leaves the method IL body`; + +const instructionBoundaryIssue = ( + start: number, + length: number, + boundaries: ReadonlySet, + name: "try" | "handler" | "filter", +): string | null => { + const end = start + length; + return boundaries.has(start) && boundaries.has(end) + ? null + : `Exception clause ${name} range does not align with CIL instruction boundaries`; +}; + +const filterOrderingIssue = ( + region: ManagedExceptionRegion, + ilSize: number, +): string | null => { + if (region.flags !== 1) return null; + const offset = region.filter_offset; + return offset !== null && offset < ilSize && offset < region.handler_offset + ? null + : "Exception clause filter must start before its handler within the method IL body"; +}; + +const clauseIssue = ( + region: ManagedExceptionRegion, + ilSize: number, + boundaries: ReadonlySet, +): string | null => + clauseFlagsIssue(region) ?? + methodExtentIssue(region.try_offset, region.try_length, ilSize, "try") ?? + instructionBoundaryIssue( + region.try_offset, + region.try_length, + boundaries, + "try", + ) ?? + methodExtentIssue( + region.handler_offset, + region.handler_length, + ilSize, + "handler", + ) ?? + instructionBoundaryIssue( + region.handler_offset, + region.handler_length, + boundaries, + "handler", + ) ?? + (region.flags === 1 && region.filter_offset !== null + ? instructionBoundaryIssue(region.filter_offset, 0, boundaries, "filter") + : null) ?? + filterOrderingIssue(region, ilSize); + +/** Validate EH extents and instruction boundaries while retaining each clause. */ +export const validateExceptionRegionRanges = ( + parsed: ExceptionRegionParseResult, + ilSize: number, + instructionOffsets: readonly number[], +): ExceptionRegionParseResult => { + if (parsed.status === "malformed") return parsed; + 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 }; + } + return parsed; +}; diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 7d8124560..650778a9e 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -111,6 +111,115 @@ describe("managed member inspection", () => { }); }); +describe("managed member reference bounds", () => { + it("retains members and reports an out-of-range TypeDef coded index as partial", () => { + const bytes = buildManagedPeFixture({ extendsRaw: 12 }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.types[0]).toMatchObject({ + token: "0x02000001", + extends_token: null, + }); + expect(result.fields).toHaveLength(1); + expect(result.methods).toHaveLength(1); + expect(result.metadata.status).toBe("partial"); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.TypeDef:0x02000001", + detail: expect.stringContaining( + "TypeDef Extends coded index 0xc is invalid: coded index selects row 3 in table 2, which has 1 rows", + ), + }, + ], + }); + expect(result.coverage.issues[0]?.offset).toBe( + (result.types[0]?.row_offset ?? 0) + 8, + ); + }); + + it("retains a MemberRef with an out-of-range parent as an unknown partial reference", () => { + const bytes = buildManagedPeFixture({ memberRefParentRaw: 40 }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.member_refs[0]).toMatchObject({ + token: "0x0a000001", + parent_token: null, + name: ".ctor", + }); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + { + code: "invalid-row", + scope: "metadata.MemberRef:0x0a000001", + detail: expect.stringContaining( + "MemberRef parent coded index 0x28 is invalid: coded index selects row 5 in table 2, which has 1 rows", + ), + }, + ], + }); + expect(result.coverage.issues[0]?.offset).toBe( + result.member_refs[0]?.row_offset, + ); + }); + + it("reports a null required MemberRef parent without dropping the reference", () => { + const bytes = buildManagedPeFixture({ memberRefParentRaw: 0 }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.member_refs[0]).toMatchObject({ + token: "0x0a000001", + parent_token: null, + }); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + detail: + "MemberRef parent coded index 0x0 is null, but the Class column must reference a row", + }), + ], + }); + }); + + it("decodes deeply nested supported pointer signatures without recursion", () => { + const depth = 8_000; + const bytes = buildManagedPeFixture({ + fieldSignature: Buffer.concat([ + Buffer.from([0x06]), + Buffer.alloc(depth, 0x0f), + Buffer.from([0x08]), + ]), + }); + const result = inspectManagedMembersBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.fields[0]?.signature).toMatchObject({ + kind: "field", + parse_status: "decoded", + field_type: `${"i4"}${"*".repeat(depth)}`, + issue: null, + }); + expect(result.methods[0]?.body.status).toBe("present"); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); + }); +}); + it.each([ Buffer.alloc(0), Buffer.from([0]), diff --git a/src/dotnet/ManagedMemberInspector.ts b/src/dotnet/ManagedMemberInspector.ts index 8e3b6ef3d..23a2435ea 100644 --- a/src/dotnet/ManagedMemberInspector.ts +++ b/src/dotnet/ManagedMemberInspector.ts @@ -116,9 +116,11 @@ export const inspectManagedMembersBytes = ( const { layout, inventory } = readMemberInventory(bytes, pe); issues.push(...inventory.issues); const ranges = typeRanges(bytes, layout); - const types = parseTypes(bytes, layout, ranges); + const parsedTypes = parseTypes(bytes, layout, ranges); + issues.push(...parsedTypes.issues); const fields = parseFields(bytes, layout, ranges); const memberRefs = parseMemberRefs(bytes, layout); + issues.push(...memberRefs.issues); const methods = parseMethods({ bytes, layout, @@ -150,7 +152,7 @@ export const inspectManagedMembersBytes = ( requires_artifact_sha256: target.sha256, requires_mvid: inventory.module?.mvid ?? null, }, - types, + types: parsedTypes.types, fields: fields.fields, methods: methods.methods, member_refs: memberRefs.refs, diff --git a/src/dotnet/ManagedMemberInspectorCore.ts b/src/dotnet/ManagedMemberInspectorCore.ts index 227a93cc9..db17acdc1 100644 --- a/src/dotnet/ManagedMemberInspectorCore.ts +++ b/src/dotnet/ManagedMemberInspectorCore.ts @@ -2,7 +2,10 @@ import { managedFailure, ManagedReaderFailure, } from "./ManagedReaderFailure.js"; -import type { ManagedMemberInspection } from "../domain/managed/managedArtifact.js"; +import type { + ManagedMemberInspection, + ManagedParseIssue, +} from "../domain/managed/managedArtifact.js"; import { type ManagedMetadataLayout, type MetadataTableLayout, @@ -10,6 +13,7 @@ import { import { metadataRowCursor, metadataCodedToken, + metadataCodedTokenInvalidReason, metadataToken, readMetadataString, sha256Bytes, @@ -190,11 +194,15 @@ export const parseTypes = ( bytes: Buffer, layout: ManagedMetadataLayout, ranges: readonly TypeRange[], -): readonly ManagedType[] => { +): { + readonly types: readonly ManagedType[]; + readonly issues: readonly ManagedParseIssue[]; +} => { const typeTable = layout.table(2); const fields = layout.table(4); const methods = layout.table(6); const items: ManagedType[] = []; + const issues: ManagedParseIssue[] = []; for (let row = 1; row <= (typeTable?.rowCount ?? 0); row += 1) { const cursor = metadataRowCursor(bytes, layout, 2, row); const flags = cursor.readUInt32(); @@ -210,6 +218,7 @@ export const parseTypes = ( cursor.readIndex(layout.stringIndexSize), layout.strings.size, ); + const extendsOffset = cursor.offset; const extendsRaw = cursor.readIndex(layout.codedIndexSize("TypeDefOrRef")); const fieldStart = cursor.readIndex(layout.tableIndexSize(4)); const methodStart = cursor.readIndex(layout.tableIndexSize(6)); @@ -220,6 +229,19 @@ export const parseTypes = ( methodStart, nextRange?.methodStart ?? 0, ); + const extendsReason = metadataCodedTokenInvalidReason( + extendsRaw, + 2, + [2, 1, 27], + layout.rowCounts, + ); + if (extendsReason !== null) + issues.push({ + code: "invalid-row", + scope: `metadata.TypeDef:${metadataToken(2, row)}`, + offset: extendsOffset, + detail: `TypeDef Extends coded index 0x${extendsRaw.toString(16)} is invalid: ${extendsReason}`, + }); items.push({ token: metadataToken(2, row), row_offset: cursor.start, @@ -227,7 +249,12 @@ export const parseTypes = ( name, full_name: fullName(namespace, name), flags, - extends_token: metadataCodedToken(extendsRaw, 2, [2, 1, 27]), + extends_token: metadataCodedToken( + extendsRaw, + 2, + [2, 1, 27], + layout.rowCounts, + ), field_list: { first_row: fieldRange.first, last_row: fieldRange.last, @@ -240,7 +267,7 @@ export const parseTypes = ( }, }); } - return items; + return { types: items, issues }; }; const readCompressed = ( @@ -301,55 +328,48 @@ const ELEMENT_TYPES = new Map([ [0x1c, "object"], ]); -const ELEMENT_TYPE_SUFFIXES = new Map([ - [0x0f, "*"], - [0x10, "&"], -]); - const readTypeSignature = ( blob: Buffer, offset: number, ): { readonly value: string; readonly next: number } => { - const kind = blob[offset]; + const suffixes: string[] = []; + let cursor = offset; + let kind = blob[cursor]; + while (kind === 0x0f || kind === 0x10 || kind === 0x1d) { + suffixes.push(kind === 0x0f ? "*" : kind === 0x10 ? "&" : "[]"); + cursor += 1; + kind = blob[cursor]; + } if (kind === undefined) throw managedFailure( "invalid-blob", "signature", "truncated type signature", - offset, + cursor, ); const named = ELEMENT_TYPES.get(kind); - if (named !== undefined) return { value: named, next: offset + 1 }; - const suffix = ELEMENT_TYPE_SUFFIXES.get(kind); - if (suffix !== undefined) { - const inner = readTypeSignature(blob, offset + 1); - return { value: `${inner.value}${suffix}`, next: inner.next }; + let value: string; + let next: number; + if (named !== undefined) { + value = named; + next = cursor + 1; + } else if (kind === 0x11 || kind === 0x12) { + const token = readCompressed(blob, cursor + 1); + value = `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`; + next = token.next; + } else if (kind === 0x1e || kind === 0x13) { + const variable = readCompressed(blob, cursor + 1); + value = `${kind === 0x1e ? "mvar" : "var"}:${String(variable.value)}`; + next = variable.next; + } else { + throw managedFailure( + "unsupported-signature", + "signature", + `unsupported element type 0x${kind.toString(16)}`, + cursor, + ); } - if (kind === 0x11 || kind === 0x12) { - const token = readCompressed(blob, offset + 1); - return { - value: `${kind === 0x11 ? "valuetype" : "class"}:${String(token.value)}`, - next: token.next, - }; - } - if (kind === 0x1d) { - const inner = readTypeSignature(blob, offset + 1); - return { value: `${inner.value}[]`, next: inner.next }; - } - if (kind === 0x1e) { - const variable = readCompressed(blob, offset + 1); - return { value: `mvar:${String(variable.value)}`, next: variable.next }; - } - if (kind === 0x13) { - const variable = readCompressed(blob, offset + 1); - return { value: `var:${String(variable.value)}`, next: variable.next }; - } - throw managedFailure( - "unsupported-signature", - "signature", - `unsupported element type 0x${kind.toString(16)}`, - offset, - ); + return { value: value + suffixes.reverse().join(""), next }; }; const callingConvention = (value: number): string => { diff --git a/src/dotnet/ManagedMemberInstructionDecoder.ts b/src/dotnet/ManagedMemberInstructionDecoder.ts index ea61cc289..7c8a11a05 100644 --- a/src/dotnet/ManagedMemberInstructionDecoder.ts +++ b/src/dotnet/ManagedMemberInstructionDecoder.ts @@ -9,6 +9,14 @@ const opcode = ( ): { readonly name: string; readonly operand: string } | undefined => OPCODES.get(code); +const PREFIX_OPCODES = new Set([ + "unaligned.", + "volatile.", + "tail.", + "constrained.", + "readonly.", +]); + const OPCODES = new Map< number, { readonly name: string; readonly operand: string } @@ -379,12 +387,15 @@ export const decodeInstructions = ( il: Buffer, ): { readonly parsed: readonly ParsedInstruction[]; + readonly instructionStarts: readonly number[]; readonly count: number; readonly truncated: number; readonly issue: string | null; } => { const parsed: ParsedInstruction[] = []; + const instructionStarts: number[] = []; let offset = 0; + let prefixStart: number | null = null; let issue: string | null = null; try { while (offset < il.length) { @@ -403,6 +414,12 @@ export const decodeInstructions = ( break; } offset = operand.next; + if (PREFIX_OPCODES.has(descriptor.name)) { + prefixStart ??= start; + } else { + instructionStarts.push(prefixStart ?? start); + prefixStart = null; + } parsed.push({ offset: start, opcode: descriptor.name, @@ -415,7 +432,7 @@ export const decodeInstructions = ( cause instanceof Error ? cause.message : "Instruction decode failed"; } const truncated = offset < il.length && issue === null ? 1 : 0; - return { parsed, count: parsed.length, truncated, issue }; + return { parsed, instructionStarts, count: parsed.length, truncated, issue }; }; export const parseExceptionRegions = ( @@ -435,12 +452,14 @@ export const parseExceptionRegions = ( ) return malformedExceptionRegions( "Exception section header leaves artifact", + regions, ); const kind = bytes.readUInt8(sectionOffset); if ((kind & 0x3f) !== 1) return malformedExceptionRegions( `Unsupported method data section kind ${String(kind & 0x3f)}`, + regions, ); const fat = (kind & 0x40) !== 0; const size = fat @@ -456,10 +475,12 @@ export const parseExceptionRegions = ( ) return malformedExceptionRegions( "Exception section size leaves artifact", + regions, ); if ((size - 4) % clauseSize !== 0) return malformedExceptionRegions( "Exception section size does not contain whole clauses", + regions, ); if ( !fat && @@ -467,6 +488,7 @@ export const parseExceptionRegions = ( ) return malformedExceptionRegions( "Small exception section reserved bytes are nonzero", + regions, ); const count = (size - 4) / clauseSize; @@ -478,24 +500,21 @@ export const parseExceptionRegions = ( return { status: "complete", regions }; }; -interface ExceptionRegionParseSuccess { - readonly status: "complete"; - readonly regions: ManagedExceptionRegion[]; -} - -interface ExceptionRegionParseFailure { - readonly status: "malformed"; - readonly regions: []; - readonly issue: string; -} - -type ExceptionRegionParseResult = - | ExceptionRegionParseSuccess - | ExceptionRegionParseFailure; +export type ExceptionRegionParseResult = + | { + readonly status: "complete"; + readonly regions: ManagedExceptionRegion[]; + } + | { + readonly status: "malformed"; + readonly regions: ManagedExceptionRegion[]; + readonly issue: string; + }; const malformedExceptionRegions = ( issue: string, -): ExceptionRegionParseFailure => ({ status: "malformed", regions: [], issue }); + regions: ManagedExceptionRegion[] = [], +): ExceptionRegionParseResult => ({ status: "malformed", regions, issue }); const readExceptionClauses = ( bytes: Buffer, diff --git a/src/dotnet/ManagedMemberRows.ts b/src/dotnet/ManagedMemberRows.ts index d948e54f7..e9cb8019c 100644 --- a/src/dotnet/ManagedMemberRows.ts +++ b/src/dotnet/ManagedMemberRows.ts @@ -1,4 +1,5 @@ import type { ManagedPeLayout } from "./ManagedPeReader.js"; +import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js"; import type { ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { createDeclaringTypeLookup, @@ -17,6 +18,7 @@ import { metadataToken, metadataRowCursor, metadataCodedToken, + metadataCodedTokenInvalidReason, readMetadataBlob, readMetadataString, } from "./ManagedMetadataHeaps.js"; @@ -71,9 +73,11 @@ export const parseMemberRefs = ( ): { readonly refs: readonly ManagedMemberRef[]; readonly core: ReadonlyMap; + readonly issues: readonly ManagedParseIssue[]; } => { const refs: ManagedMemberRef[] = []; const core = new Map(); + const issues: ManagedParseIssue[] = []; const table = layout.table(10); for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { const cursor = metadataRowCursor(bytes, layout, 10, row); @@ -93,16 +97,37 @@ export const parseMemberRefs = ( layout.blob.size, ); const token = metadataToken(10, row); + const parentReason = metadataCodedTokenInvalidReason( + parentRaw, + 3, + [2, 1, 26, 6, 27], + layout.rowCounts, + ); + if (parentReason !== null || parentRaw === 0) + issues.push({ + code: "invalid-row", + scope: `metadata.MemberRef:${token}`, + offset: cursor.start, + detail: + parentReason === null + ? "MemberRef parent coded index 0x0 is null, but the Class column must reference a row" + : `MemberRef parent coded index 0x${parentRaw.toString(16)} is invalid: ${parentReason}`, + }); refs.push({ token, row_offset: cursor.start, - parent_token: metadataCodedToken(parentRaw, 3, [2, 1, 26, 6, 27]), + parent_token: metadataCodedToken( + parentRaw, + 3, + [2, 1, 26, 6, 27], + layout.rowCounts, + ), name, signature: signature(sig), }); core.set(token, { token, name }); } - return { refs, core }; + return { refs, core, issues }; }; interface ParseMethodsInput { diff --git a/src/dotnet/ManagedMetadataHeaps.ts b/src/dotnet/ManagedMetadataHeaps.ts index 9994a41a4..ab59caa21 100644 --- a/src/dotnet/ManagedMetadataHeaps.ts +++ b/src/dotnet/ManagedMetadataHeaps.ts @@ -253,10 +253,32 @@ export const metadataCodedToken = ( raw: number, bits: number, tables: readonly (number | undefined)[], + rowCounts: readonly number[], ): string | null => { if (raw === 0) return null; const tag = raw & (2 ** bits - 1); const row = Math.floor(raw / 2 ** bits); const table = tables[tag]; - return table === undefined || row === 0 ? null : metadataToken(table, row); + return table === undefined || row === 0 || row > (rowCounts[table] ?? 0) + ? null + : metadataToken(table, row); +}; + +/** Explain why a non-null coded index cannot name an admitted metadata row. */ +export const metadataCodedTokenInvalidReason = ( + raw: number, + bits: number, + tables: readonly (number | undefined)[], + rowCounts: readonly number[], +): string | null => { + if (raw === 0) return null; + const tag = raw & (2 ** bits - 1); + const row = Math.floor(raw / 2 ** bits); + const table = tables[tag]; + if (table === undefined) return `coded index tag ${tag} is not defined`; + if (row === 0) return `coded index selects row zero in table ${table}`; + const count = rowCounts[table] ?? 0; + return row > count + ? `coded index selects row ${row} in table ${table}, which has ${count} rows` + : null; }; diff --git a/src/dotnet/ManagedMetadataInventory.ts b/src/dotnet/ManagedMetadataInventory.ts index f0eadaaa0..a347fe620 100644 --- a/src/dotnet/ManagedMetadataInventory.ts +++ b/src/dotnet/ManagedMetadataInventory.ts @@ -73,17 +73,48 @@ const uniqueIssues = ( const heapExtent = (layout: ManagedMetadataLayout): number => Math.max(layout.strings.size, layout.blob.size); +const validateIdentityTableCounts = ( + layout: ManagedMetadataLayout, + issues: ManagedParseIssue[], +): void => { + const moduleRows = layout.table(0)?.rowCount ?? 0; + if (moduleRows !== 1) + issues.push({ + code: "invalid-row", + scope: "metadata.Module", + offset: layout.table(0)?.offset ?? null, + detail: `Module table must contain exactly one row; found ${String(moduleRows)}`, + }); + + const assemblyRows = layout.table(32)?.rowCount ?? 0; + if (assemblyRows > 1) + issues.push({ + code: "invalid-row", + scope: "metadata.Assembly", + offset: layout.table(32)?.offset ?? null, + detail: `Assembly table can contain at most one row; found ${String(assemblyRows)}`, + }); +}; + const readReferences = ( bytes: Buffer, layout: ManagedMetadataLayout, issues: ManagedParseIssue[], -): readonly AssemblyReference[] => - readRows(layout.table(35), (row) => +): { + readonly references: readonly AssemblyReference[]; + readonly referenceNames: readonly string[]; +} => { + const references = readRows(layout.table(35), (row) => safeRead( () => readAssemblyReference(bytes, layout, row, heapExtent(layout)), issues, ), ); + return { + references, + referenceNames: [...new Set(references.map(({ name }) => name))].sort(), + }; +}; const readResources = ({ bytes, @@ -114,53 +145,27 @@ const readAttributes = ( bytes: Buffer, layout: ManagedMetadataLayout, issues: ManagedParseIssue[], -): readonly CustomAttribute[] => - readRows(layout.table(12), (row) => +): { + readonly attributes: readonly CustomAttribute[]; + readonly targetFrameworks: readonly string[]; +} => { + const attributes = readRows(layout.table(12), (row) => safeRead( () => readCustomAttribute(bytes, layout, row, heapExtent(layout)), issues, ), ); - -const collectReferenceNames = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - issues: ManagedParseIssue[], -): readonly string[] => { - const names: string[] = []; - const referenceTable = layout.table(35); - for (let row = 1; row <= (referenceTable?.rowCount ?? 0); row += 1) { - const reference = safeRead( - () => readAssemblyReference(bytes, layout, row, heapExtent(layout)), - issues, - ); - if (reference !== undefined) names.push(reference.name); - } - return names; -}; - -const collectTargetFrameworks = ( - bytes: Buffer, - layout: ManagedMetadataLayout, - issues: ManagedParseIssue[], -): readonly string[] => { - const targetFrameworks: string[] = []; - const attributeTable = layout.table(12); - for (let row = 1; row <= (attributeTable?.rowCount ?? 0); row += 1) { - const attribute = safeRead( - () => readCustomAttribute(bytes, layout, row, heapExtent(layout)), - issues, - ); + const targetFrameworks = new Set(); + for (const attribute of attributes) { if ( - attribute?.parent_token === metadataToken(32, 1) && + attribute.parent_token === metadataToken(32, 1) && attribute.type_name === "System.Runtime.Versioning.TargetFrameworkAttribute" && - attribute.decoded_fixed_string !== null && - !targetFrameworks.includes(attribute.decoded_fixed_string) + attribute.decoded_fixed_string !== null ) - targetFrameworks.push(attribute.decoded_fixed_string); + targetFrameworks.add(attribute.decoded_fixed_string); } - return targetFrameworks; + return { attributes, targetFrameworks: [...targetFrameworks].sort() }; }; /** Inventory identity tables without CLR reflection or execution. */ @@ -170,27 +175,30 @@ export const readManagedMetadataInventory = ( resourceDirectory: ManagedResourceDirectory | null, ): ManagedMetadataInventory => { const issues: ManagedParseIssue[] = []; + validateIdentityTableCounts(layout, issues); const module = safeRead(() => readModule(bytes, layout, heapExtent(layout)), issues) ?? null; const assembly = safeRead(() => readAssembly(bytes, layout, heapExtent(layout)), issues) ?? null; - const references = readReferences(bytes, layout, issues); + const { references, referenceNames } = readReferences(bytes, layout, issues); const resources = readResources({ bytes, layout, resourceDirectory, issues, }); - const attributes = readAttributes(bytes, layout, issues); - const referenceNames = collectReferenceNames(bytes, layout, issues); - const targetFrameworks = collectTargetFrameworks(bytes, layout, issues); + const { attributes, targetFrameworks } = readAttributes( + bytes, + layout, + issues, + ); return { module, assembly, - targetFrameworks: [...targetFrameworks].sort(), - referenceNames: [...new Set(referenceNames)].sort(), + targetFrameworks, + referenceNames, references, resources, attributes, diff --git a/src/dotnet/ManagedMetadataInventoryRows.ts b/src/dotnet/ManagedMetadataInventoryRows.ts index 836c9a376..596cb519c 100644 --- a/src/dotnet/ManagedMetadataInventoryRows.ts +++ b/src/dotnet/ManagedMetadataInventoryRows.ts @@ -9,6 +9,7 @@ import { import { metadataRowCursor, metadataCodedToken, + metadataCodedTokenInvalidReason, metadataToken, readMetadataBlob, readMetadataGuid, @@ -365,6 +366,7 @@ export const readCustomAttribute = ( const parentRaw = cursor.readIndex( layout.codedIndexSize("HasCustomAttribute"), ); + const typeOffset = cursor.offset; const typeRaw = cursor.readIndex( layout.codedIndexSize("CustomAttributeType"), ); @@ -381,23 +383,48 @@ export const readCustomAttribute = ( 6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42, 44, 43, ], + layout.rowCounts, + ); + const parentReason = metadataCodedTokenInvalidReason( + parentRaw, + 5, + [ + 6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42, + 44, 43, + ], + layout.rowCounts, ); if (parent === null) throw managedFailure( "invalid-row", - "metadata.CustomAttribute", - "CustomAttribute parent coded index is invalid", + `metadata.CustomAttribute:${metadataToken(12, row)}`, + `CustomAttribute parent coded index 0x${parentRaw.toString(16)} is invalid${parentReason === null ? " because the required parent is null" : `: ${parentReason}`}`, cursor.start, ); + const typeReason = metadataCodedTokenInvalidReason( + typeRaw, + 3, + [undefined, undefined, 6, 10], + layout.rowCounts, + ); + if (typeReason !== null || typeRaw === 0) + throw managedFailure( + "invalid-row", + `metadata.CustomAttribute:${metadataToken(12, row)}`, + typeReason === null + ? "CustomAttribute constructor coded index 0x0 is null, but Type must reference a MethodDef or MemberRef row" + : `CustomAttribute constructor coded index 0x${typeRaw.toString(16)} is invalid: ${typeReason}`, + typeOffset, + ); const typeName = attributeTypeName(bytes, layout, typeRaw, heapExtent); return { parent_token: parent, - constructor_token: metadataCodedToken(typeRaw, 3, [ - undefined, - undefined, - 6, - 10, - ]), + constructor_token: metadataCodedToken( + typeRaw, + 3, + [undefined, undefined, 6, 10], + layout.rowCounts, + ), type_name: typeName, value_length: value.length, value_sha256: sha256Bytes(value), @@ -434,6 +461,7 @@ export const readResource = ({ cursor.readIndex(layout.stringIndexSize), Math.max(layout.strings.size, layout.blob.size), ); + const implementationOffset = cursor.offset; const implementationRaw = cursor.readIndex( layout.codedIndexSize("Implementation"), ); @@ -441,7 +469,21 @@ export const readResource = ({ implementationRaw, 2, [38, 35, 39], + layout.rowCounts, ); + const implementationReason = metadataCodedTokenInvalidReason( + implementationRaw, + 2, + [38, 35, 39], + layout.rowCounts, + ); + if (implementationReason !== null) + issues.push({ + code: "invalid-row", + scope: `metadata.ManifestResource:${metadataToken(40, row)}`, + offset: implementationOffset, + detail: `ManifestResource implementation coded index 0x${implementationRaw.toString(16)} is invalid: ${implementationReason}`, + }); let dataLength: number | null = null; let dataSha256: string | null = null; if (implementationRaw === 0) { diff --git a/src/dotnet/ManagedMethodBodyReader.ts b/src/dotnet/ManagedMethodBodyReader.ts index b8f0d2948..fd1c5e642 100644 --- a/src/dotnet/ManagedMethodBodyReader.ts +++ b/src/dotnet/ManagedMethodBodyReader.ts @@ -4,6 +4,7 @@ import { decodeInstructions, parseExceptionRegions, } from "./ManagedMemberInstructionDecoder.js"; +import { validateExceptionRegionRanges } from "./ManagedExceptionRegionValidation.js"; import { sha256Bytes } from "./ManagedMetadataHeaps.js"; const bodyStatus = ( @@ -60,6 +61,19 @@ 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, @@ -82,7 +96,13 @@ export const methodBody = ( ); try { const offset = pe.rvaToOffset(rva, 1, "method.body"); + const methodExtent = pe.rvaAvailableBytes(rva, "method.body"); const header = readMethodBodyHeader(bytes, offset); + if ( + header.size > methodExtent || + header.ilSize > methodExtent - header.size + ) + throw new RangeError("Method body leaves file-backed PE section data"); const ilOffset = offset + header.size; if (ilOffset > bytes.length - header.ilSize) throw new RangeError("Method IL bytes leave artifact"); @@ -114,7 +134,13 @@ export const methodBody = ( const sectionOffset = (methodEnd + 3) & ~3; const exceptionRegions = header.format === "fat" && (header.flags & 8) !== 0 - ? parseExceptionRegions(bytes, sectionOffset, bytes.length) + ? readExceptionRegions( + bytes, + sectionOffset, + offset + methodExtent, + header.ilSize, + decoded.instructionStarts, + ) : null; const status = bodyStatus( decoded.issue, diff --git a/src/dotnet/ManagedNativeBoundaryHelpers.ts b/src/dotnet/ManagedNativeBoundaryHelpers.ts index 5ef2e3bbb..3ba7d81c4 100644 --- a/src/dotnet/ManagedNativeBoundaryHelpers.ts +++ b/src/dotnet/ManagedNativeBoundaryHelpers.ts @@ -8,6 +8,7 @@ import { type ManagedMetadataLayout } from "./ManagedMetadataLayout.js"; import { metadataRowCursor, metadataCodedToken, + metadataCodedTokenInvalidReason, metadataToken, readMetadataString, } from "./ManagedMetadataHeaps.js"; @@ -127,6 +128,7 @@ interface ParseImplMapsContext { readonly heapExtent: number; readonly modules: readonly ModuleRef[]; readonly members: ReadonlyMap; + readonly issues: ManagedParseIssue[]; } export const parseImplMaps = ({ @@ -135,6 +137,7 @@ export const parseImplMaps = ({ heapExtent, modules, members, + issues, }: ParseImplMapsContext): readonly NativeImport[] => { const moduleNames = new Map(modules.map((module) => [module.token, module])); const imports: NativeImport[] = []; @@ -142,10 +145,31 @@ export const parseImplMaps = ({ for (let row = 1; row <= (table?.rowCount ?? 0); row += 1) { const cursor = metadataRowCursor(bytes, layout, 28, row); const mappingFlags = cursor.readUInt16(); - const memberToken = metadataCodedToken( - cursor.readIndex(layout.codedIndexSize("MemberForwarded")), + const memberForwardedOffset = cursor.offset; + const memberForwardedRaw = cursor.readIndex( + layout.codedIndexSize("MemberForwarded"), + ); + const memberForwardedReason = metadataCodedTokenInvalidReason( + memberForwardedRaw, 1, [4, 6], + layout.rowCounts, + ); + if (memberForwardedReason !== null || memberForwardedRaw === 0) + issues.push({ + code: "invalid-row", + scope: `metadata.ImplMap:${metadataToken(28, row)}`, + offset: memberForwardedOffset, + detail: + memberForwardedReason === null + ? "ImplMap MemberForwarded coded index 0x0 is null, but the column must reference a Field or MethodDef row" + : `ImplMap MemberForwarded coded index 0x${memberForwardedRaw.toString(16)} is invalid: ${memberForwardedReason}`, + }); + const memberToken = metadataCodedToken( + memberForwardedRaw, + 1, + [4, 6], + layout.rowCounts, ); const importName = readMetadataString( bytes, diff --git a/src/dotnet/ManagedNativeBoundaryInspector.test.ts b/src/dotnet/ManagedNativeBoundaryInspector.test.ts index 70c49248d..1f2a702ef 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.test.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; + +import { inspectManagedArtifactBytes } from "./ManagedArtifactInspector.js"; import { inspectManagedMembersBytes } from "./ManagedMemberInspector.js"; import { inspectManagedNativeBoundariesBytes } from "./ManagedNativeBoundaryInspector.js"; import { @@ -68,11 +71,74 @@ describe("managed native boundaries", () => { ready_to_run: true, mixed_mode_or_native_header: true, }); + expect(result.coverage).toMatchObject({ state: "complete", issues: [] }); expect(result.limitations).toContain( "P/Invoke rows prove managed import declarations only; this inspection does not verify that a native library, export, thunk, or provider-qualified function exists.", ); }); +}); +describe("managed native boundary coded indexes", () => { + it("retains P/Invoke imports with an invalid MemberForwarded index as partial evidence", () => { + const bytes = buildManagedPeFixture({ + pinvoke: { memberForwardedRaw: 5 }, + }); + const result = inspectManagedNativeBoundariesBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.pinvoke_imports).toMatchObject([ + { + member_token: null, + member_kind: "unknown", + member_name: null, + import_name: "MessageBoxW", + }, + ]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + scope: "metadata.ImplMap:0x1c000001", + detail: expect.stringContaining( + "coded index 0x5 is invalid: coded index selects row 2 in table 6, which has 1 rows", + ), + }), + ], + }); + expect(result.coverage.issues[0]?.offset).toBe( + (result.pinvoke_imports[0]?.row_offset ?? 0) + 2, + ); + }); + + it("reports null for the required MemberForwarded index", () => { + const bytes = buildManagedPeFixture({ + pinvoke: { memberForwardedRaw: 0 }, + }); + const result = inspectManagedNativeBoundariesBytes( + bytes, + managedPeFixtureTarget(bytes), + ); + + expect(result.pinvoke_imports).toMatchObject([ + { member_token: null, member_kind: "unknown" }, + ]); + expect(result.coverage).toMatchObject({ + state: "partial", + issues: [ + expect.objectContaining({ + code: "invalid-row", + detail: + "ImplMap MemberForwarded coded index 0x0 is null, but the column must reference a Field or MethodDef row", + }), + ], + }); + }); +}); + +describe("managed native boundary inspection", () => { it("returns complete member inventories inline and reports unavailable metadata", () => { const bytes = buildManagedPeFixture(); const paged = inspectManagedMembersBytes( @@ -158,3 +224,34 @@ describe("managed native boundaries", () => { ); }); }); + +describe("managed PE fixture layout", () => { + it("keeps ReadyToRun metadata separate from expanded metadata and resources", () => { + const resourceData = Buffer.alloc(64 * 1024, 0x5a); + const bytes = buildManagedPeFixture({ + readyToRun: true, + references: Array.from( + { length: 2_200 }, + (_, index) => `Reference.${String(index).padStart(4, "0")}`, + ), + resourceData, + }); + const target = managedPeFixtureTarget(bytes); + const artifact = inspectManagedArtifactBytes(bytes, target); + const boundaries = inspectManagedNativeBoundariesBytes(bytes, target); + + expect(artifact.coverage).toMatchObject({ state: "complete", issues: [] }); + expect(artifact.references).toHaveLength(2_200); + expect(artifact.resources[0]).toMatchObject({ + data_length: resourceData.length, + data_sha256: createHash("sha256").update(resourceData).digest("hex"), + }); + expect(boundaries.cli_native).toMatchObject({ + ready_to_run_signature: true, + managed_native_header_size: 4, + }); + expect(boundaries.cli_native.managed_native_header_rva).toBeGreaterThan( + 0x2700, + ); + }); +}); diff --git a/src/dotnet/ManagedNativeBoundaryInspector.ts b/src/dotnet/ManagedNativeBoundaryInspector.ts index a0e58f3c0..2bed713ba 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.ts @@ -156,6 +156,7 @@ export const inspectManagedNativeBoundariesBytes = ( }); } const heapExtent = Math.max(layout.strings.size, layout.blob.size); + const issues = [...inventory.issues]; const moduleRefs = parseModuleRefs(bytes, layout, heapExtent); const members = new Map([ ...parseFields(bytes, layout, heapExtent), @@ -167,6 +168,7 @@ export const inspectManagedNativeBoundariesBytes = ( heapExtent, modules: moduleRefs, members, + issues, }); const pinvokeTokens = new Set( imports @@ -187,6 +189,6 @@ export const inspectManagedNativeBoundariesBytes = ( imports, implementations, native: cliNative(pe), - issues: [], + issues, }); }; diff --git a/src/dotnet/ManagedPe.fixture.ts b/src/dotnet/ManagedPe.fixture.ts index 076a4cd8e..05d035ab6 100644 --- a/src/dotnet/ManagedPe.fixture.ts +++ b/src/dotnet/ManagedPe.fixture.ts @@ -5,22 +5,31 @@ import type { BinaryTarget } from "../domain/binaryTarget.js"; interface ManagedPeFixtureOptions { readonly cliFlags?: number; readonly corruptMetadataSignature?: boolean; + readonly customAttributeTypeRaw?: number; + readonly extendsRaw?: number; readonly fieldName?: string; readonly fieldSignature?: Buffer; readonly ilBody?: Buffer; + readonly malformedAssemblyReferenceRows?: readonly number[]; + readonly malformedCustomAttributeRows?: readonly number[]; readonly metadataValidMaskExtra?: bigint; readonly methodName?: string; readonly methodSignature?: Buffer; + readonly moduleRowCount?: number; + readonly assemblyRowCount?: number; + readonly memberRefParentRaw?: number; readonly mvid?: Buffer; readonly pinvoke?: { readonly importName?: string; + readonly memberForwardedRaw?: number; readonly mappingFlags?: number; readonly moduleName?: string; }; readonly readyToRun?: boolean; readonly references?: readonly string[]; + readonly resourceImplementationRaw?: number; readonly resourceData?: Buffer; - readonly targetFramework?: string; + readonly targetFramework?: string | null; readonly typeName?: string; readonly typeNamespace?: string; } @@ -35,6 +44,10 @@ class StringHeap { readonly #chunks: Buffer[] = [Buffer.from([0])]; #size = 1; + get size(): number { + return this.#size; + } + add(value: string): number { if (value.length === 0) return 0; const index = this.#size; @@ -53,13 +66,26 @@ class BlobHeap { readonly #chunks: Buffer[] = [Buffer.from([0])]; #size = 1; + get size(): number { + return this.#size; + } + add(value: Buffer): number { if (value.length === 0) return 0; - if (value.length > 0x7f) - throw new RangeError("Managed PE fixture blob is too large"); const index = this.#size; - this.#chunks.push(Buffer.from([value.length]), value); - this.#size += value.length + 1; + const prefix = + value.length <= 0x7f + ? Buffer.from([value.length]) + : value.length <= 0x3fff + ? Buffer.from([0x80 | (value.length >> 8), value.length & 0xff]) + : Buffer.from([ + 0xc0 | (value.length >> 24), + (value.length >> 16) & 0xff, + (value.length >> 8) & 0xff, + value.length & 0xff, + ]); + this.#chunks.push(prefix, value); + this.#size += value.length + prefix.length; return index; } @@ -106,33 +132,92 @@ const u32 = (value: number): Buffer => { return bytes; }; -const moduleRow = (name: number): Buffer => - Buffer.concat([u16(0), u16(name), u16(1), u16(0), u16(0)]); +type MetadataIndexSize = 2 | 4; -const typeRefRow = (name: number, namespace: number): Buffer => - Buffer.concat([u16(0), u16(name), u16(namespace)]); +const indexSize = (rows: number, codedTagBits = 0): MetadataIndexSize => + rows < 2 ** (16 - codedTagBits) ? 2 : 4; -const typeDefRow = ( +const metadataIndex = (value: number, size: MetadataIndexSize): Buffer => + size === 2 ? u16(value) : u32(value); + +const codedIndexSize = ( + rows: readonly number[], + tagBits: number, +): MetadataIndexSize => indexSize(Math.max(...rows), tagBits); + +const moduleRow = ( name: number, - namespace: number, - fieldList: number, - methodList: number, + stringSize: MetadataIndexSize, + guidSize: MetadataIndexSize, ): Buffer => Buffer.concat([ - u32(0x0010_0001), - u16(name), - u16(namespace), - u16((1 << 2) | 1), - u16(fieldList), - u16(methodList), + u16(0), + metadataIndex(name, stringSize), + metadataIndex(1, guidSize), + metadataIndex(0, guidSize), + metadataIndex(0, guidSize), ]); -const fieldRow = (name: number, signature: number): Buffer => - Buffer.concat([u16(0x0001), u16(name), u16(signature)]); +const typeRefRow = ( + name: number, + namespace: number, + stringSize: MetadataIndexSize, + resolutionScopeSize: MetadataIndexSize, +): Buffer => + Buffer.concat([ + metadataIndex(0, resolutionScopeSize), + metadataIndex(name, stringSize), + metadataIndex(namespace, stringSize), + ]); + +const typeDefRow = ({ + name, + namespace, + fieldList, + methodList, + stringSize, + typeDefOrRefSize, + fieldListSize, + methodListSize, + extendsRaw = (1 << 2) | 1, +}: { + readonly name: number; + readonly namespace: number; + readonly fieldList: number; + readonly methodList: number; + readonly stringSize: MetadataIndexSize; + readonly typeDefOrRefSize: MetadataIndexSize; + readonly fieldListSize: MetadataIndexSize; + readonly methodListSize: MetadataIndexSize; + readonly extendsRaw?: number; +}): Buffer => + Buffer.concat([ + u32(0x0010_0001), + metadataIndex(name, stringSize), + metadataIndex(namespace, stringSize), + metadataIndex(extendsRaw, typeDefOrRefSize), + metadataIndex(fieldList, fieldListSize), + metadataIndex(methodList, methodListSize), + ]); + +const fieldRow = ( + name: number, + signature: number, + stringSize: MetadataIndexSize, + blobSize: MetadataIndexSize, +): Buffer => + Buffer.concat([ + u16(0x0001), + metadataIndex(name, stringSize), + metadataIndex(signature, blobSize), + ]); interface MethodDefRowInput { readonly name: number; readonly signature: number; + readonly stringSize: MetadataIndexSize; + readonly blobSize: MetadataIndexSize; + readonly parameterSize: MetadataIndexSize; readonly rva?: number; readonly flags?: number; readonly implFlags?: number; @@ -141,6 +226,9 @@ interface MethodDefRowInput { const methodDefRow = ({ name, signature, + stringSize, + blobSize, + parameterSize, rva = 0, flags = 0x0016, implFlags = 0, @@ -149,29 +237,56 @@ const methodDefRow = ({ u32(rva), u16(implFlags), u16(flags), - u16(name), - u16(signature), - u16(0), + metadataIndex(name, stringSize), + metadataIndex(signature, blobSize), + metadataIndex(0, parameterSize), ]); -const memberRefRow = (name: number, signature: number): Buffer => - Buffer.concat([u16((1 << 3) | 1), u16(name), u16(signature)]); +const memberRefRow = ({ + name, + signature, + stringSize, + blobSize, + parentSize, + parentRaw = (1 << 3) | 1, +}: { + readonly name: number; + readonly signature: number; + readonly stringSize: MetadataIndexSize; + readonly blobSize: MetadataIndexSize; + readonly parentSize: MetadataIndexSize; + readonly parentRaw?: number; +}): Buffer => + Buffer.concat([ + metadataIndex(parentRaw, parentSize), + metadataIndex(name, stringSize), + metadataIndex(signature, blobSize), + ]); -const moduleRefRow = (name: number): Buffer => Buffer.concat([u16(name)]); +const moduleRefRow = (name: number, stringSize: MetadataIndexSize): Buffer => + Buffer.concat([metadataIndex(name, stringSize)]); const implMapRow = ( importName: number, importScope: number, mappingFlags: number, + stringSize: MetadataIndexSize, + memberForwardedSize: MetadataIndexSize, + moduleRefSize: MetadataIndexSize, + memberForwardedRaw: number, ): Buffer => Buffer.concat([ u16(mappingFlags), - u16((1 << 1) | 1), - u16(importName), - u16(importScope), + metadataIndex(memberForwardedRaw, memberForwardedSize), + metadataIndex(importName, stringSize), + metadataIndex(importScope, moduleRefSize), ]); -const assemblyRow = (name: number): Buffer => +const assemblyRow = ( + name: number, + stringSize: MetadataIndexSize, + blobSize: MetadataIndexSize, +): Buffer => Buffer.concat([ u32(0x0000_8004), u16(1), @@ -179,29 +294,71 @@ const assemblyRow = (name: number): Buffer => u16(3), u16(4), u32(0), - u16(0), - u16(name), - u16(0), + metadataIndex(0, blobSize), + metadataIndex(name, stringSize), + metadataIndex(0, stringSize), ]); -const assemblyRefRow = (name: number, keyOrToken: number): Buffer => +const assemblyRefRow = ( + name: number, + keyOrToken: number, + malformedName: boolean, + stringSize: MetadataIndexSize, + blobSize: MetadataIndexSize, +): Buffer => Buffer.concat([ u16(8), u16(0), u16(0), u16(0), u32(0), - u16(keyOrToken), - u16(name), - u16(0), - u16(0), + metadataIndex(keyOrToken, blobSize), + metadataIndex( + malformedName ? (stringSize === 2 ? 0xffff : 0xffff_ffff) : name, + stringSize, + ), + metadataIndex(0, stringSize), + metadataIndex(0, blobSize), ]); -const customAttributeRow = (value: number): Buffer => - Buffer.concat([u16((1 << 5) | 14), u16((1 << 3) | 3), u16(value)]); +const customAttributeRow = ( + value: number, + options: ManagedPeFixtureOptions, + parentIndexSize: 2 | 4, + typeIndexSize: MetadataIndexSize, + blobSize: MetadataIndexSize, +): Buffer => + Buffer.concat([ + parentIndexSize === 2 + ? u16( + options.malformedCustomAttributeRows?.includes(1) + ? 0xffff + : (1 << 5) | 14, + ) + : u32( + options.malformedCustomAttributeRows?.includes(1) + ? 0xffff_ffff + : (1 << 5) | 14, + ), + metadataIndex( + options.customAttributeTypeRaw ?? (1 << 3) | 3, + typeIndexSize, + ), + metadataIndex(value, blobSize), + ]); -const manifestResourceRow = (name: number): Buffer => - Buffer.concat([u32(0), u32(2), u16(name), u16(0)]); +const manifestResourceRow = ( + name: number, + stringSize: MetadataIndexSize, + implementationSize: MetadataIndexSize, + implementationRaw: number, +): Buffer => + Buffer.concat([ + u32(0), + u32(2), + metadataIndex(name, stringSize), + metadataIndex(implementationRaw, implementationSize), + ]); const fixedStringAttributeBlob = (value: string): Buffer => { const bytes = Buffer.from(textEncoder.encode(value)); @@ -270,11 +427,13 @@ const metadataRoot = ( const tablesStream = ( rows: ReadonlyMap, extraValidMask: bigint, + heapSizes: number, ): Buffer => { let valid = extraValidMask; for (const table of rows.keys()) valid |= 1n << BigInt(table); const header = Buffer.alloc(24); header.writeUInt8(2, 4); + header.writeUInt8(heapSizes, 6); header.writeUInt8(1, 7); header.writeBigUInt64LE(valid, 8); const counts: Buffer[] = []; @@ -292,8 +451,269 @@ const tablesStream = ( return Buffer.concat([header, ...counts, ...data]); }; +interface FixtureMetadataRowContext { + readonly options: ManagedPeFixtureOptions; + readonly methodRva: number; + readonly referenceStringIndexes: readonly number[]; + readonly indexes: { + readonly moduleName: number; + readonly assemblyName: number; + readonly attributeName: number; + readonly attributeNamespace: number; + readonly constructorName: number; + readonly typeName: number; + readonly typeNamespace: number; + readonly fieldName: number; + readonly methodName: number; + readonly pinvokeModuleName: number | null; + readonly pinvokeImportName: number | null; + readonly resourceName: number; + readonly tokenBlob: number; + readonly fieldSignature: number; + readonly methodSignature: number; + readonly constructorSignature: number; + readonly attributeBlob: number; + }; + readonly heapSizes: { + readonly strings: MetadataIndexSize; + readonly blobs: MetadataIndexSize; + readonly guids: MetadataIndexSize; + }; +} + +interface FixtureIndexWidths { + readonly table: (index: number) => MetadataIndexSize; + readonly coded: ( + tables: readonly number[], + tagBits: number, + ) => MetadataIndexSize; + readonly resolutionScope: MetadataIndexSize; + readonly typeDefOrRef: MetadataIndexSize; + readonly memberRefParent: MetadataIndexSize; + readonly hasCustomAttribute: MetadataIndexSize; + readonly customAttributeType: MetadataIndexSize; + readonly implementation: MetadataIndexSize; +} + +const repeatedRows = ( + count: number, + createRow: () => Buffer, +): readonly Buffer[] => Array.from({ length: count }, createRow); + +const customAttributeRows = ( + { + options, + indexes, + heapSizes, + }: Pick, + widths: FixtureIndexWidths, +): readonly Buffer[] => + options.targetFramework === null + ? [] + : [ + customAttributeRow( + indexes.attributeBlob, + options, + widths.hasCustomAttribute, + widths.customAttributeType, + heapSizes.blobs, + ), + ]; + +const fixtureIndexWidths = ({ + options, + referenceStringIndexes, + indexes, +}: FixtureMetadataRowContext): FixtureIndexWidths => { + const rowCounts = new Map([ + [0, options.moduleRowCount ?? 1], + [1, 1], + [2, 1], + [4, 1], + [6, 1], + [8, 0], + [10, 1], + [12, options.targetFramework === null ? 0 : 1], + ...(indexes.pinvokeModuleName === null ? [] : ([[26, 1]] as const)), + ...(indexes.pinvokeImportName === null ? [] : ([[28, 1]] as const)), + [32, options.assemblyRowCount ?? 1], + [35, referenceStringIndexes.length], + [40, 1], + ]); + const count = (table: number): number => rowCounts.get(table) ?? 0; + const table = (index: number): MetadataIndexSize => indexSize(count(index)); + const coded = ( + tables: readonly number[], + tagBits: number, + ): MetadataIndexSize => codedIndexSize(tables.map(count), tagBits); + return { + table, + coded, + resolutionScope: coded([0, 26, 35, 1], 2), + typeDefOrRef: coded([2, 1, 27], 2), + memberRefParent: coded([2, 1, 26, 6, 27], 3), + hasCustomAttribute: coded( + [ + 6, 4, 1, 2, 8, 9, 10, 0, 14, 23, 20, 17, 26, 27, 32, 35, 38, 39, 40, 42, + 44, 43, + ], + 5, + ), + customAttributeType: coded([6, 10], 3), + implementation: coded([38, 35, 39], 2), + }; +}; + +const fixtureMetadataRows = ({ + options, + methodRva, + referenceStringIndexes, + indexes, + heapSizes, +}: FixtureMetadataRowContext): ReadonlyMap => { + const widths = fixtureIndexWidths({ + options, + methodRva, + referenceStringIndexes, + indexes, + heapSizes, + }); + const rows = new Map([ + [ + 0, + repeatedRows(options.moduleRowCount ?? 1, () => + moduleRow(indexes.moduleName, heapSizes.strings, heapSizes.guids), + ), + ], + [ + 1, + [ + typeRefRow( + indexes.attributeName, + indexes.attributeNamespace, + heapSizes.strings, + widths.resolutionScope, + ), + ], + ], + [ + 2, + [ + typeDefRow({ + name: indexes.typeName, + namespace: indexes.typeNamespace, + fieldList: 1, + methodList: 1, + stringSize: heapSizes.strings, + typeDefOrRefSize: widths.typeDefOrRef, + fieldListSize: widths.table(4), + methodListSize: widths.table(6), + ...(options.extendsRaw === undefined + ? {} + : { extendsRaw: options.extendsRaw }), + }), + ], + ], + [ + 4, + [ + fieldRow( + indexes.fieldName, + indexes.fieldSignature, + heapSizes.strings, + heapSizes.blobs, + ), + ], + ], + [ + 6, + [ + methodDefRow({ + name: indexes.methodName, + signature: indexes.methodSignature, + stringSize: heapSizes.strings, + blobSize: heapSizes.blobs, + parameterSize: widths.table(8), + rva: methodRva, + flags: options.pinvoke === undefined ? 0x0016 : 0x2016, + }), + ], + ], + [ + 10, + [ + memberRefRow({ + name: indexes.constructorName, + signature: indexes.constructorSignature, + stringSize: heapSizes.strings, + blobSize: heapSizes.blobs, + parentSize: widths.memberRefParent, + ...(options.memberRefParentRaw === undefined + ? {} + : { parentRaw: options.memberRefParentRaw }), + }), + ], + ], + [12, customAttributeRows({ options, indexes, heapSizes }, widths)], + ...(indexes.pinvokeModuleName === null + ? [] + : ([ + [26, [moduleRefRow(indexes.pinvokeModuleName, heapSizes.strings)]], + ] as const)), + ...(indexes.pinvokeImportName === null + ? [] + : ([ + [ + 28, + [ + implMapRow( + indexes.pinvokeImportName, + 1, + options.pinvoke?.mappingFlags ?? 0x0344, + heapSizes.strings, + widths.coded([4, 6], 1), + widths.table(26), + options.pinvoke?.memberForwardedRaw ?? (1 << 1) | 1, + ), + ], + ], + ] as const)), + [ + 32, + repeatedRows(options.assemblyRowCount ?? 1, () => + assemblyRow(indexes.assemblyName, heapSizes.strings, heapSizes.blobs), + ), + ], + [ + 35, + referenceStringIndexes.map((name, index) => + assemblyRefRow( + name, + indexes.tokenBlob, + options.malformedAssemblyReferenceRows?.includes(index + 1) ?? false, + heapSizes.strings, + heapSizes.blobs, + ), + ), + ], + [ + 40, + [ + manifestResourceRow( + indexes.resourceName, + heapSizes.strings, + widths.implementation, + options.resourceImplementationRaw ?? 0, + ), + ], + ], + ]); + return rows; +}; + const buildManagedFixtureMetadata = ( options: ManagedPeFixtureOptions, + methodRva: number, ): Buffer => { const strings = new StringHeap(); const blobs = new BlobHeap(); @@ -329,50 +749,45 @@ const buildManagedFixtureMetadata = ( const constructorSignature = blobs.add(Buffer.from([0x20, 0x01, 0x01, 0x0e])); const attributeBlob = blobs.add( fixedStringAttributeBlob( - options.targetFramework ?? ".NETCoreApp,Version=v8.0", + options.targetFramework === null + ? ".NETCoreApp,Version=v8.0" + : (options.targetFramework ?? ".NETCoreApp,Version=v8.0"), ), ); - const rows = new Map([ - [0, [moduleRow(moduleName)]], - [1, [typeRefRow(attributeName, attributeNamespace)]], - [2, [typeDefRow(typeName, typeNamespace, 1, 1)]], - [4, [fieldRow(fieldName, fieldSignature)]], - [ - 6, - [ - methodDefRow({ - name: methodName, - signature: methodSignature, - rva: 0x2800, - flags: options.pinvoke === undefined ? 0x0016 : 0x2016, - }), - ], - ], - [10, [memberRefRow(constructorName, constructorSignature)]], - [12, [customAttributeRow(attributeBlob)]], - ...(pinvokeModuleName === null - ? [] - : ([[26, [moduleRefRow(pinvokeModuleName)]]] as const)), - ...(pinvokeImportName === null - ? [] - : ([ - [ - 28, - [ - implMapRow( - pinvokeImportName, - 1, - options.pinvoke?.mappingFlags ?? 0x0344, - ), - ], - ], - ] as const)), - [32, [assemblyRow(assemblyName)]], - [35, referenceStringIndexes.map((name) => assemblyRefRow(name, tokenBlob))], - [40, [manifestResourceRow(resourceName)]], - ]); + const stringSize = indexSize(strings.size); + const blobSize = indexSize(blobs.size); + const guidSize = indexSize(guidHeap(options.mvid).length); + const rows = fixtureMetadataRows({ + options, + methodRva, + referenceStringIndexes, + indexes: { + moduleName, + assemblyName, + attributeName, + attributeNamespace, + constructorName, + typeName, + typeNamespace, + fieldName, + methodName, + pinvokeModuleName, + pinvokeImportName, + resourceName, + tokenBlob, + fieldSignature, + methodSignature, + constructorSignature, + attributeBlob, + }, + heapSizes: { strings: stringSize, blobs: blobSize, guids: guidSize }, + }); + const heapFlags = + (stringSize === 4 ? 0x01 : 0) | + (guidSize === 4 ? 0x02 : 0) | + (blobSize === 4 ? 0x04 : 0); const metadata = metadataRoot( - tablesStream(rows, options.metadataValidMaskExtra ?? 0n), + tablesStream(rows, options.metadataValidMaskExtra ?? 0n, heapFlags), strings.toBuffer(), guidHeap(options.mvid), blobs.toBuffer(), @@ -383,14 +798,56 @@ const buildManagedFixtureMetadata = ( const buildManagedFixtureImage = ( options: ManagedPeFixtureOptions, - metadata: Buffer, + buildMetadata: (methodRva: number) => Buffer, ): Buffer => { const resourceData = options.resourceData ?? Buffer.from("resource-data"); const resourceDirectory = Buffer.concat([ u32(resourceData.length), resourceData, ]); - const image = Buffer.alloc(0x1000); + const initialMetadata = buildMetadata(0); + const resourceOffset = Math.max( + 0x0800, + Math.ceil((0x0300 + initialMetadata.length) / 0x0200) * 0x0200, + ); + const resourceRva = 0x2000 + resourceOffset - 0x0200; + const bodyOffset = Math.max( + 0x0a00, + Math.ceil((resourceOffset + resourceDirectory.length) / 0x0200) * 0x0200, + ); + const methodRva = 0x2000 + bodyOffset - 0x0200; + const metadata = buildMetadata(methodRva); + const body = + options.ilBody ?? + Buffer.from([ + 0x32, 0x02, 0x7b, 0x01, 0x00, 0x00, 0x04, 0x28, 0x01, 0x00, 0x00, 0x0a, + 0x2a, + ]); + const legacyReadyToRunOffset = 0x0900; + const metadataEnd = 0x0300 + metadata.length; + const resourceEnd = resourceOffset + resourceDirectory.length; + const bodyEnd = bodyOffset + body.length; + const overlapsReadyToRun = (start: number, end: number): boolean => + legacyReadyToRunOffset < end && legacyReadyToRunOffset + 4 > start; + const readyToRunOffset = + options.readyToRun === true + ? overlapsReadyToRun(0x0300, metadataEnd) || + overlapsReadyToRun(resourceOffset, resourceEnd) || + overlapsReadyToRun(bodyOffset, bodyEnd) + ? Math.ceil(Math.max(metadataEnd, resourceEnd, bodyEnd) / 4) * 4 + : legacyReadyToRunOffset + : null; + const rawSectionSize = + Math.ceil( + Math.max( + 0x0e00, + 0x0100 + metadata.length, + bodyOffset - 0x0200 + body.length, + readyToRunOffset === null ? 0 : readyToRunOffset - 0x0200 + 4, + ) / 0x0200, + ) * 0x0200; + const virtualSectionSize = Math.max(0x1000, rawSectionSize); + const image = Buffer.alloc(0x0200 + rawSectionSize); image.write("MZ", 0, "ascii"); image.writeUInt32LE(0x80, 0x3c); image.writeUInt32LE(0x0000_4550, 0x80); @@ -405,16 +862,19 @@ const buildManagedFixtureImage = ( image.writeUInt32LE(0x0040_0000, optional + 28); image.writeUInt32LE(0x1000, optional + 32); image.writeUInt32LE(0x200, optional + 36); - image.writeUInt32LE(0x3000, optional + 56); + image.writeUInt32LE( + Math.ceil((0x2000 + virtualSectionSize) / 0x1000) * 0x1000, + optional + 56, + ); image.writeUInt32LE(0x200, optional + 60); image.writeUInt32LE(16, optional + 92); image.writeUInt32LE(0x2000, optional + 96 + 14 * 8); image.writeUInt32LE(72, optional + 96 + 14 * 8 + 4); const section = optional + 0x00e0; image.write(".text\0\0\0", section, "ascii"); - image.writeUInt32LE(0x1000, section + 8); + image.writeUInt32LE(virtualSectionSize, section + 8); image.writeUInt32LE(0x2000, section + 12); - image.writeUInt32LE(0x0e00, section + 16); + image.writeUInt32LE(rawSectionSize, section + 16); image.writeUInt32LE(0x0200, section + 20); image.writeUInt32LE(0x6000_0020, section + 36); const cli = 0x0200; @@ -425,22 +885,16 @@ const buildManagedFixtureImage = ( image.writeUInt32LE(metadata.length, cli + 12); image.writeUInt32LE(options.cliFlags ?? 1, cli + 16); image.writeUInt32LE(0x0600_0001, cli + 20); - image.writeUInt32LE(0x2600, cli + 24); + image.writeUInt32LE(resourceRva, cli + 24); image.writeUInt32LE(resourceDirectory.length, cli + 28); - if (options.readyToRun === true) { - image.writeUInt32LE(0x2700, cli + 64); + if (readyToRunOffset !== null) { + image.writeUInt32LE(0x2000 + readyToRunOffset - 0x0200, cli + 64); image.writeUInt32LE(4, cli + 68); - image.write("RTR\0", 0x0900, "ascii"); + image.write("RTR\0", readyToRunOffset, "ascii"); } - ( - options.ilBody ?? - Buffer.from([ - 0x32, 0x02, 0x7b, 0x01, 0x00, 0x00, 0x04, 0x28, 0x01, 0x00, 0x00, 0x0a, - 0x2a, - ]) - ).copy(image, 0x0a00); + body.copy(image, bodyOffset); metadata.copy(image, 0x0300); - resourceDirectory.copy(image, 0x0800); + resourceDirectory.copy(image, resourceOffset); return image; }; @@ -448,7 +902,9 @@ const buildManagedFixtureImage = ( export const buildManagedPeFixture = ( options: ManagedPeFixtureOptions = {}, ): Buffer => - buildManagedFixtureImage(options, buildManagedFixtureMetadata(options)); + buildManagedFixtureImage(options, (methodRva) => + buildManagedFixtureMetadata(options, methodRva), + ); /** Build a syntactically valid PE32 fixture with no CLI directory. */ export const buildNativePeFixture = (): Buffer => { diff --git a/src/dotnet/ManagedPeReader.ts b/src/dotnet/ManagedPeReader.ts index 2f2f7fdba..c888a2b4b 100644 --- a/src/dotnet/ManagedPeReader.ts +++ b/src/dotnet/ManagedPeReader.ts @@ -41,6 +41,7 @@ export interface ManagedPeLayout { readonly cliDirectoryPresent: boolean; readonly cliIssue: ManagedParseIssue | null; rvaToOffset(rva: number, size: number, scope: string): number; + rvaAvailableBytes(rva: number, scope: string): number; } interface RvaMapping { @@ -355,5 +356,22 @@ 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`, + ); + }, }; };