From 8b3902d3776e9f6be9ec699fbfb88c1a1c9dc356 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:55:39 +0800 Subject: [PATCH] fix(native): own complete metadata mappings and ambiguity Resolve Go pointer descriptors and strings across complete, consistent file-backed spans, including PE headers and adjacent ELF load ranges. Reuse the canonical image-mapping selection for native exports. Preserve existing gaps, alias, zero-fill and capacity refusals. Forward parsed zero-fill ownership through IL2CPP relocation tables, destinations and mapped-span checks. Distinguish missing spans from ambiguous ownership in the shared mapping failure: unresolved relocation targets may remain unknown, while ambiguous raw registration bytes cannot be reused as a resolved count. Validation: parser, provider, service, native-export, IL2CPP and public CLI/MCP regressions cover PE32/PE32+ headers, split ELF descriptors/contents, relocation count conflicts, ordinary unresolved targets and unchanged inputs. Authored carriers establish static parsing, not Windows loading or actual legacy Go linker emission. Fixes #6831 Fixes #6830 --- docs/go-binaries.md | 12 ++ docs/il2cpp-metadata.md | 2 + src/go/GoBinaryImage.ts | 32 ++- src/il2cpp/ElfRelocations.ts | 15 +- src/il2cpp/Il2cppBinaryImage.ts | 7 +- src/native/exports/NativeExportParser.ts | 6 +- src/native/metadata/BinaryContainer.ts | 71 +++++-- .../goBinaryPointerMappingsCli.test.ts | 79 ++++++++ tests/boundary/go/goBinaryImage.test.ts | 18 ++ tests/boundary/go/goBinaryMappings.test.ts | 52 +++++ .../il2cpp/il2cppMappingOwnership.test.ts | 187 ++++++++++++++++++ tests/fixtures/go/image.ts | 86 ++++++++ 12 files changed, 526 insertions(+), 41 deletions(-) create mode 100644 tests/acceptance/goBinaryPointerMappingsCli.test.ts create mode 100644 tests/boundary/il2cpp/il2cppMappingOwnership.test.ts diff --git a/docs/go-binaries.md b/docs/go-binaries.md index 0a5ace4a20..0f832700e1 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -116,6 +116,18 @@ including pointer-backed strings. Legacy pointer-backed fields are decoded at the width declared by Go's build-info record, independently of the image's ELF class. It does not scan arbitrary byte strings as compiler metadata. +Legacy string descriptors and contents may use the declared file-backed PE +headers. A descriptor or string may also cross adjacent loader mappings when +their virtual ranges cover every requested byte and identify one contiguous +file range. Gaps, conflicting file aliases and nonempty zero-fill overlap remain +invalid. Discovery policy and the aggregate 1 MiB string-content limit are +unchanged. These are static reads over REA's native mapping facts; they do not +establish Windows/Linux loading or parity with Go's standard reader, whose +[Go 1.24.0 ELF/PE readers](https://github.com/golang/go/blob/go1.24.0/src/debug/buildinfo/buildinfo.go#L445) +read within individual segments or sections. The historical two-word string +descriptor follows the +[Go 1.17.13 reader](https://github.com/golang/go/blob/go1.17.13/src/cmd/go/internal/version/version.go#L212). + A universal (FAT) Mach-O needs `architecture` (`x86`, `x86_64`, `arm` or `arm64`; CLI `--architecture`). REA reads its own FAT table, without `lipo`, and validates the selected slice's range, alignment and Mach-O header CPU diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 3d32989ece..72d55efba6 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -304,6 +304,8 @@ Scans yield cooperatively in bounded batches. Name-address retention and reverse ELF RELATIVE writes into zero-fill are kept as a private virtual-pointer overlay. Unresolved supported-width symbol relocations, including RELA PLT entries, remain unknown only for the bytes their ELF ABI relocation type covers; an adjacent 32-bit relocation does not make a separate 64-bit method slot unknown. Stored zeros never establish no-body. COPY, TLS descriptor, REL, packed APS2 and other unsupported relocation encodings fail closed. Input bytes are unchanged. +ELF relocation tables and file-backed destinations may span adjacent load ranges when every range agrees on the file translation. Any overlapping zero-fill declaration makes those bytes ambiguous; REA refuses the table or relocation destination instead of choosing the stored file bytes. Missing file bytes remain distinct from conflicting owners: supported unresolved relocations outside file-backed memory retain their unknown overlay, while ambiguous destinations refuse linking. Registration coverage uses the same file/zero-fill ownership checks as pointer reads. + Native address queries batch exact64-bit hexadecimal `addresses` and `states` (`code`, `no-body`, `unknown`), intersecting indices and pagination. Numerically equal address spellings match; original row indices and every shared-body alternative survive: ```json diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 563dbd9a57..781c43c69b 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -3,9 +3,11 @@ import { BinaryResourceFailure, BinaryReader, mappedFileOffset, + imageFileMappings, isZeroFilledSpan, type BinaryContainer, type FileMapping, + type ZeroFillMapping, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import { @@ -88,15 +90,11 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { : { section_names_limitation: sectionNamesLimitation }), }; const { search, header } = selected; + const mappings = imageFileMappings(container); const verifyMapping = (size: number): void => { const address = search.address + BigInt(header - search.offset); if ( - mappedFileOffset( - container.mappings, - address, - size, - container.zero_fills, - ) !== header + mappedFileOffset(mappings, address, size, container.zero_fills) !== header ) throw new BinaryFormatFailure( "malformed", @@ -159,14 +157,16 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { const pointerBits = ptrSize === 4 ? 32 : 64; version = readPointerString( reader, - container, + mappings, + container.zero_fills, reader.word(header + 16, pointerBits), pointerBits, ); const moduleField = header + 16 + ptrSize; module = readOptionalModuleString( reader, - container, + mappings, + container.zero_fills, reader.word(moduleField, pointerBits), moduleField, pointerBits, @@ -346,11 +346,11 @@ const readInlineExtent = ( const readPointerString = ( reader: BinaryReader, - container: BinaryContainer, + mappings: readonly FileMapping[], + zeroFills: readonly ZeroFillMapping[], address: bigint, pointerBits: 32 | 64, ): LocatedString => { - const { mappings, zero_fills: zeroFills } = container; const width = pointerBits / 8; const header = mappedFileOffset(mappings, address, width * 2, zeroFills); const valueAddress = reader.word(header, pointerBits); @@ -375,19 +375,15 @@ const readPointerString = ( const readOptionalModuleString = ( reader: BinaryReader, - container: BinaryContainer, + mappings: readonly FileMapping[], + zeroFills: readonly ZeroFillMapping[], address: bigint, pointerField: number, pointerBits: 32 | 64, ): LocatedString => - isZeroFilledSpan( - container.mappings, - container.zero_fills, - address, - (pointerBits / 8) * 2, - ) + isZeroFilledSpan(mappings, zeroFills, address, (pointerBits / 8) * 2) ? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } } - : readPointerString(reader, container, address, pointerBits); + : readPointerString(reader, mappings, zeroFills, address, pointerBits); /** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */ export const observedUtf8 = (bytes: Buffer, label: string): string => { diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts index a3ea8465a4..041d403ef5 100644 --- a/src/il2cpp/ElfRelocations.ts +++ b/src/il2cpp/ElfRelocations.ts @@ -1,5 +1,5 @@ import { - BinaryFormatFailure, + BinaryMappingFailure, mappedFileOffset, isZeroFilledSpan, type FileMapping, @@ -124,14 +124,15 @@ function* dynamicTags(image: Buffer): Generator> { } const fileOffset = ( - mappings: readonly FileMapping[], + source: RelocationSource, address: bigint, size: number, ): number | null => { try { - return mappedFileOffset(mappings, address, size); + return mappedFileOffset(source.mappings, address, size, source.zeroFills); } catch (cause) { - if (cause instanceof BinaryFormatFailure) return null; + if (cause instanceof BinaryMappingFailure && cause.reason === "unmapped") + return null; throw cause; } }; @@ -163,7 +164,7 @@ function* applyRelaTable( size > BigInt(source.image.length) ) throw malformed("invalid DT_RELASZ or DT_RELAENT"); - const start = fileOffset(source.mappings, address, Number(size)); + const start = fileOffset(source, address, Number(size)); if (start === null) throw malformed("RELA table is not file-backed"); for (let at = start; at < start + Number(size); at += 24) { if ((at - start) % (24 * 4096) === 0) yield; @@ -180,11 +181,11 @@ function* applyRelaTable( output.unresolved.set(destination, Math.max(priorWidth ?? 0, width)); // Registration scans read this private image directly. Clear unknown // fields so their stored bytes cannot masquerade as resolved pointers. - const target = fileOffset(source.mappings, destination, width); + const target = fileOffset(source, destination, width); if (target !== null) output.bytes.fill(0, target, target + width); continue; } - const target = fileOffset(source.mappings, destination, 8); + const target = fileOffset(source, destination, 8); const value = source.image.readBigInt64LE(at + 16) & 0xffffffffffffffffn; if (target !== null) output.bytes.writeBigUInt64LE(value, target); else if ( diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 5ce825f31a..af254ba299 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -237,7 +237,12 @@ export const readUInt32 = ( export const isMapped = (image: NativeImage, address: bigint, size: number) => { try { - mappedFileOffset(image.container.mappings, address, size); + mappedFileOffset( + image.container.mappings, + address, + size, + image.container.zero_fills, + ); return true; } catch (cause) { if (cause instanceof BinaryFormatFailure) return false; diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 673c54b818..7d4883fb29 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -3,6 +3,7 @@ import { BinaryReader, BinaryResourceFailure, mappedFileOffset, + imageFileMappings, } from "../metadata/BinaryContainer.js"; import { architectureForElfMachine } from "../metadata/ElfImage.js"; import { isUtf8 } from "node:buffer"; @@ -109,10 +110,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => { ], }; const reader = new BinaryReader(bytes, true); - const mappings = - image.pe_header_mapping === undefined - ? image.mappings - : [image.pe_header_mapping, ...image.mappings]; + const mappings = imageFileMappings(image); const map = (rva: number, size: number): number => mappedFileOffset(mappings, base + BigInt(rva), size, image.zero_fills); const cString = (rva: number): string => { diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index e0742bf36a..e07c9c10b3 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -9,6 +9,16 @@ export class BinaryFormatFailure extends Error { } } +/** A missing file span is a candidate miss; conflicting owners are malformed. */ +export class BinaryMappingFailure extends BinaryFormatFailure { + constructor( + readonly reason: "unmapped" | "ambiguous", + message: string, + ) { + super("malformed", message); + } +} + /** Resource boundary exceeded while decoding metadata or native container structure. */ export class BinaryResourceFailure extends Error { constructor( @@ -121,6 +131,14 @@ export interface BinaryContainer { readonly sections: readonly ImageSection[]; } +/** All declared file-backed image memory, including PE headers at the image base. */ +export const imageFileMappings = ( + container: BinaryContainer, +): readonly FileMapping[] => + container.pe_header_mapping === undefined + ? container.mappings + : [container.pe_header_mapping, ...container.mappings]; + /** Reader that checks every structural range before decoding numeric fields. */ export class BinaryReader { constructor( @@ -212,7 +230,7 @@ export const declaredFileRange = ( }; }; -/** Map a virtual span without losing precision or selecting an ambiguous alias. */ +/** Map a contiguous file span without gaps or conflicting loader mappings. */ export const mappedFileOffset = ( mappings: readonly FileMapping[], address: bigint, @@ -230,21 +248,52 @@ export const mappedFileOffset = ( continue; const offset = mapping.offset + Number(delta); if (found !== undefined && found !== offset) - throw new BinaryFormatFailure( - "malformed", + throw new BinaryMappingFailure( + "ambiguous", "Virtual address has ambiguous file mappings", ); found = offset; } + if (found === undefined && size > 0) { + // Keep the common single-mapping path allocation-free. A split span needs + // ordered intervals to prove that every byte is backed, even when native + // mappings arrive out of address order. + const end = address + BigInt(size); + const spans = mappings + .filter((mapping) => + spansOverlap( + address, + BigInt(size), + mapping.address, + BigInt(mapping.size), + ), + ) + .sort((left, right) => + left.address < right.address + ? -1 + : left.address > right.address + ? 1 + : 0, + ); + let covered = address; + for (const mapping of spans) { + if (mapping.address > covered) break; + const mappingEnd = mapping.address + BigInt(mapping.size); + if (mappingEnd > covered) covered = mappingEnd; + } + const first = spans[0]; + if (covered >= end && first !== undefined) + found = first.offset + Number(address - first.address); + } if (found === undefined) - throw new BinaryFormatFailure( - "malformed", + throw new BinaryMappingFailure( + "unmapped", "Virtual address or string range is not file mapped", ); for (const zeroFill of zeroFills) { if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size)) - throw new BinaryFormatFailure( - "malformed", + throw new BinaryMappingFailure( + "ambiguous", "Virtual address has ambiguous file and zero-fill mappings", ); } @@ -258,8 +307,8 @@ export const mappedFileOffset = ( BigInt(found) + start - address !== BigInt(mapping.offset) + start - mapping.address ) - throw new BinaryFormatFailure( - "malformed", + throw new BinaryMappingFailure( + "ambiguous", "Virtual address has ambiguous file mappings", ); } @@ -284,8 +333,8 @@ export const isZeroFilledSpan = ( return false; for (const mapping of mappings) { if (spansOverlap(address, bytes, mapping.address, BigInt(mapping.size))) - throw new BinaryFormatFailure( - "malformed", + throw new BinaryMappingFailure( + "ambiguous", "Virtual address has ambiguous file and zero-fill mappings", ); } diff --git a/tests/acceptance/goBinaryPointerMappingsCli.test.ts b/tests/acceptance/goBinaryPointerMappingsCli.test.ts new file mode 100644 index 0000000000..6fc239d49a --- /dev/null +++ b/tests/acceptance/goBinaryPointerMappingsCli.test.ts @@ -0,0 +1,79 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { evidenceResultOf } from "../../src/contracts/toolOutputSchemaPrimitives.js"; +import { goBinarySchema } from "../../src/domain/go/goBinary.js"; +import { + createGoCrossLoadPointerFixture, + createGoPeHeaderPointerFixture, +} from "../fixtures/go/image.js"; +import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; +import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest, runFileCli } from "../support/cli/cliFixture.js"; + +cliTest( + "retains header-backed PE and cross-load ELF pointer metadata through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-go-pointer-mappings-"); + const { client, call } = await connectLocalToolsMcp(); + const cases = [ + ...([32, 64] as const).flatMap((bits) => + (["contents", "descriptors"] as const).map((placement) => ({ + name: `pe${bits}-header-${placement}`, + fixture: createGoPeHeaderPointerFixture(bits, placement), + })), + ), + ...(["contents", "descriptor"] as const).map((placement) => ({ + name: `elf-cross-load-${placement}`, + fixture: createGoCrossLoadPointerFixture(placement), + })), + ]; + for (const { name, fixture } of cases) { + const path = join(root, name); + await writeFile(path, fixture.bytes); + const result = await runFileCli(cli, { + arguments: ["inspect-go-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = evidenceResultOf(goBinarySchema).parse(result.json); + expect(evidence.normalized_result.build_info).toMatchObject({ + encoding: "pointer", + go_version: "go1.26.0", + go_version_bytes_base64: Buffer.from("go1.26.0").toString("base64"), + module_bytes_base64: fixture.moduleBytes.toString("base64"), + module: { path: "example.com/tool/cmd/tool" }, + version_location: { offset: fixture.versionOffset, bytes: 8 }, + module_location: { + offset: fixture.moduleOffset, + bytes: fixture.moduleBytes.length, + }, + }); + expect(evidence.subject?.digest?.sha256).toBe( + createHash("sha256").update(fixture.bytes).digest("hex"), + ); + const response = await call("inspect_go_binary", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + expect(await readFile(path)).toEqual(fixture.bytes); + } + + const damaged = createGoCrossLoadPointerFixture("contents"); + damaged.bytes.writeBigUInt64LE(0x11001n, 200); // A one-byte virtual gap. + const path = join(root, "elf-cross-load-gap"); + await writeFile(path, damaged.bytes); + const rejected = await runFileCli(cli, { + arguments: ["inspect-go-binary", path, "--json"], + }); + expect(rejected.exitCode).toBe(1); + expect(rejected.json).toMatchObject({ + code: "invalid_request", + details: { issues: [{ path: ["path"], reason: "invalid_format" }] }, + }); + const response = await call("inspect_go_binary", { path }); + expect(parseMcpToolError(response)).toEqual({ error: rejected.json }); + await client.ping(); + }, + 60000, +); diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index c65e2aa02b..600ec1f6d9 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -12,6 +12,7 @@ import { readMachoImage } from "../../../src/native/metadata/MachoImage.js"; import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js"; import { createGoBinaryFixture, + createGoPeHeaderPointerFixture, createGoSplitElfFixture, createGoOverlappingElfFixture, GO_MODULE_TEXT, @@ -264,6 +265,23 @@ it("refuses mapped pointer lengths outside the file instead of rounding 64-bit a expect(() => readGoBinaryImage(length.bytes)).toThrowError(/limit|length/i); }); +it.each(["file bytes", "zero-fill"])( + "refuses a header-backed PE module descriptor contradicted by %s", + (alias) => { + const { bytes } = createGoPeHeaderPointerFixture(64, "descriptors"); + const section = 152 + bytes.readUInt16LE(148) + 40; + bytes.writeUInt16LE(2, 134); + bytes.write(".alias", section, "ascii"); + bytes.writeUInt32LE(16, section + 8); + bytes.writeUInt32LE(528, section + 12); + if (alias === "file bytes") { + bytes.writeUInt32LE(16, section + 16); + bytes.writeUInt32LE(1536, section + 20); + } + expect(() => readGoBinaryImage(bytes)).toThrowError(/ambiguous/i); + }, +); + it("refuses overlong or truncated varints before decoding or allocating strings", () => { const fixture = createGoBinaryFixture(); fixture.bytes.fill( diff --git a/tests/boundary/go/goBinaryMappings.test.ts b/tests/boundary/go/goBinaryMappings.test.ts index c0b4223225..d84ab6d6d7 100644 --- a/tests/boundary/go/goBinaryMappings.test.ts +++ b/tests/boundary/go/goBinaryMappings.test.ts @@ -31,6 +31,58 @@ it("does not confuse adjacent mappings with overlapping byte spans", () => { expect(mappedFileOffset([selected, adjacent], 164n, 0)).toBe(80); }); +it("maps a split span across unsorted adjacent and agreeing overlapping ranges", () => { + expect( + mappedFileOffset( + [ + { address: 164n, offset: 80, size: 32 }, + { address: 156n, offset: 72, size: 12 }, + selected, + ], + 156n, + 24, + ), + ).toBe(72); +}); + +it.each([ + { address: 165n, offset: 81, size: 32 }, // One virtual byte is unbacked. + { address: 164n, offset: 96, size: 32 }, // Virtual adjacency, different file translation. +])( + "refuses a split span with a gap or discontinuous file bytes", + (adjacent) => { + expect(() => mappedFileOffset([selected, adjacent], 156n, 24)).toThrowError( + /mapped|ambiguous/i, + ); + }, +); + +it("checks aliases and zero-fill inside the later part of a split span", () => { + const adjacent = { address: 164n, offset: 80, size: 32 }; + expect(() => + mappedFileOffset( + [selected, adjacent, { address: 170n, offset: 4, size: 4 }], + 156n, + 24, + ), + ).toThrowError(/ambiguous/i); + expect(() => + mappedFileOffset([selected, adjacent], 156n, 24, [ + { address: 170n, size: 4n }, + ]), + ).toThrowError(/ambiguous/i); +}); + +it("keeps ambiguous zero-length endpoints invalid", () => { + expect(() => + mappedFileOffset( + [selected, { address: 164n, offset: 96, size: 32 }], + 164n, + 0, + ), + ).toThrowError(/ambiguous/i); +}); + it.each([ { address: 132n, size: 4n }, { address: 112n, size: 12n }, diff --git a/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts new file mode 100644 index 0000000000..536b6751fc --- /dev/null +++ b/tests/boundary/il2cpp/il2cppMappingOwnership.test.ts @@ -0,0 +1,187 @@ +import { expect, it } from "vitest"; +import { linkIl2cppBinary } from "../../../src/il2cpp/Il2cppBinaryFile.js"; +import { + isMapped, + openImageSteps, + readPointer, + readSlot, +} from "../../../src/il2cpp/Il2cppBinaryImage.js"; +import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadataFile.js"; +import { + createElfFixture, + createIl2cppBinaryFixture, + FIXTURE_BINARY_BASE, +} from "../../fixtures/il2cpp/binary.js"; +import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js"; + +const { metadata, link: facts } = readIl2cppMetadataForLinking( + createLinkedIl2cppMetadataFixture().bytes, +); +const link = (bytes: Buffer) => linkIl2cppBinary(bytes, metadata, facts); +const open = (bytes: Buffer) => { + const steps = openImageSteps(bytes, undefined); + let step = steps.next(); + while (!step.done) step = steps.next(); + return step.value; +}; + +/** Replace PT_LOAD declarations, keeping the original PT_DYNAMIC and payload. */ +const withLoadRanges = ( + bytes: Buffer, + ranges: readonly { + readonly offset: number; + readonly size: number; + readonly zeroFill?: number; + }[], +): Buffer => { + const headers: Buffer[] = []; + const start = Number(bytes.readBigUInt64LE(32)); + const width = bytes.readUInt16LE(54); + for (let index = 0; index < bytes.readUInt16LE(56); index++) { + const at = start + index * width; + if (bytes.readUInt32LE(at) !== 1) + headers.push(bytes.subarray(at, at + width)); + } + for (const range of ranges) { + const header = Buffer.alloc(width); + header.writeUInt32LE(1); + header.writeUInt32LE(7, 4); + header.writeBigUInt64LE(BigInt(range.offset), 8); + const address = FIXTURE_BINARY_BASE + BigInt(range.offset); + header.writeBigUInt64LE(address, 16); + header.writeBigUInt64LE(address, 24); + header.writeBigUInt64LE(BigInt(range.size), 32); + header.writeBigUInt64LE(BigInt(range.size + (range.zeroFill ?? 0)), 40); + header.writeBigUInt64LE(1n, 48); + headers.push(header); + } + const result = Buffer.concat([bytes, ...headers]); + result.writeBigUInt64LE(BigInt(bytes.length), 32); + result.writeUInt16LE(headers.length, 56); + return result; +}; + +it.each([183, 62])( + "links agreeing split ELF RELA spans without changing input bytes (machine %s)", + (machine) => { + const original = createElfFixture(createIl2cppBinaryFixture(), { machine }); + // Split the first Elf64_Rela after its destination and half its info word. + const split = withLoadRanges(original, [ + { offset: 0, size: 0x100c }, + { offset: 0x100c, size: original.length - 0x100c }, + ]); + const snapshot = Buffer.from(split); + expect(link(split)).toEqual(link(original)); + expect(split).toEqual(snapshot); + }, +); + +it.each([false, true])( + "refuses a RELA addend claimed by file bytes and zero-fill (split %s)", + (split) => { + const original = createElfFixture(createIl2cppBinaryFixture()); + const bytes = withLoadRanges(original, [ + ...(split + ? [ + { offset: 0, size: 0x100c }, + { offset: 0x100c, size: original.length - 0x100c }, + ] + : [{ offset: 0, size: original.length }]), + { offset: 0x1010, size: 0, zeroFill: 8 }, + ]); + const snapshot = Buffer.from(bytes); + expect(() => link(bytes)).toThrow( + expect.objectContaining({ + reason: "malformed-container", + message: expect.stringContaining( + "ambiguous file and zero-fill mappings", + ), + }), + ); + expect(bytes).toEqual(snapshot); + }, +); + +it("refuses a RELATIVE destination claimed by both file bytes and zero-fill", () => { + const original = createElfFixture(createIl2cppBinaryFixture()); + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { offset: 0x304, size: 0, zeroFill: 4 }, + ]); + expect(() => link(bytes)).toThrow( + expect.objectContaining({ + reason: "malformed-container", + message: expect.stringContaining("ambiguous file and zero-fill mappings"), + }), + ); +}); + +it("refuses an unresolved count relocation with conflicting file/zero-fill owners", () => { + const original = createElfFixture(createIl2cppBinaryFixture(), { + unresolvedRelocations: [{ offset: 0x600, kind: 258 }], // R_AARCH64_ABS32 + }); + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + { offset: 0x600, size: 0, zeroFill: 4 }, + ]); + const snapshot = Buffer.from(bytes); + expect(() => link(bytes)).toThrow( + expect.objectContaining({ + reason: "malformed-container", + message: expect.stringContaining("ambiguous file and zero-fill mappings"), + }), + ); + expect(bytes).toEqual(snapshot); +}); + +it("clears a file-backed unresolved count before scanning registration candidates", () => { + const control = createElfFixture(createIl2cppBinaryFixture()); + const bytes = createElfFixture(createIl2cppBinaryFixture(), { + unresolvedRelocations: [{ offset: 0x600, kind: 258 }], + }); + const snapshot = Buffer.from(bytes); + expect(link(control).code_gen_modules_field).toBe( + FIXTURE_BINARY_BASE + 0x600n, + ); + expect(() => link(bytes)).toThrow( + expect.objectContaining({ reason: "code-registration-not-found" }), + ); + expect(bytes).toEqual(snapshot); +}); + +it.each([false, true])( + "retains unrelated unresolved targets without file bytes (zero-fill %s)", + (zeroFill) => { + const control = createElfFixture(createIl2cppBinaryFixture()); + const original = createElfFixture(createIl2cppBinaryFixture(), { + unresolvedRelocations: [{ offset: 0x2000, kind: 258 }], + }); + const bytes = withLoadRanges(original, [ + { offset: 0, size: original.length }, + ...(zeroFill ? [{ offset: 0x2000, size: 0, zeroFill: 4 }] : []), + ]); + expect(link(bytes)).toEqual(link(control)); + expect(readSlot(open(bytes), FIXTURE_BINARY_BASE + 0x2000n)).toBe( + "unknown", + ); + }, +); + +it("keeps mapped-span admission consistent with pointer reads across zero-fill aliases", () => { + const original = createElfFixture(createIl2cppBinaryFixture()); + const ranges = [ + { offset: 0, size: 0xc08 }, + { offset: 0xc08, size: original.length - 0xc08 }, + ]; + for (const conflict of [false, true]) { + const bytes = withLoadRanges(original, [ + ...ranges, + ...(conflict ? [{ offset: 0xc08, size: 0, zeroFill: 4 }] : []), + ]); + const image = open(bytes); + const address = FIXTURE_BINARY_BASE + 0xc04n; + expect(isMapped(image, address, 8)).toBe(!conflict); + expect(readPointer(image, address)).toBe(conflict ? null : 0n); + expect(isMapped(image, FIXTURE_BINARY_BASE + 0xc00n, 4)).toBe(true); + } +}); diff --git a/tests/fixtures/go/image.ts b/tests/fixtures/go/image.ts index 2dda4dea26..cdac2a5ab8 100644 --- a/tests/fixtures/go/image.ts +++ b/tests/fixtures/go/image.ts @@ -141,6 +141,92 @@ export const createGoBinaryFixture = ( return { bytes, headerOffset, versionOffset, moduleOffset, moduleBytes }; }; +/** Legacy strings in declared PE header memory, with the record still in .data. */ +export const createGoPeHeaderPointerFixture = ( + bits: 32 | 64, + placement: "contents" | "descriptors", +) => { + const fixture = createGoBinaryFixture({ + format: "pe", + bits, + encoding: "pointer", + }); + const { bytes, headerOffset } = fixture; + const width = bits / 8; + const word = (offset: number, value: number) => { + if (bits === 64) bytes.writeBigUInt64LE(BigInt(value), offset); + else bytes.writeUInt32LE(value, offset); + }; + const section = 152 + bytes.readUInt16LE(148); + bytes.writeUInt16LE(0x22, 150); + bytes.writeUInt32LE(4096, 184); // SectionAlignment. + bytes.writeUInt32LE(512, 188); // FileAlignment. + bytes.writeUInt32LE(8192, 208); // SizeOfImage. + bytes.writeUInt32LE(1024, 212); // SizeOfHeaders. + bytes.writeUInt32LE(4096, section + 12); + const sectionAddress = (offset: number) => 0x11000 + offset - 1024; + word(headerOffset + 16, sectionAddress(1536)); + word(headerOffset + 16 + width, sectionAddress(1536 + width * 2)); + word(1536, sectionAddress(fixture.versionOffset)); + word(1536 + width * 2, sectionAddress(fixture.moduleOffset)); + if (placement === "descriptors") { + bytes.copy(bytes, 512, 1536, 1536 + width * 4); + word(headerOffset + 16, 0x10200); + word(headerOffset + 16 + width, 0x10200 + width * 2); + return fixture; + } + const versionOffset = 512; + const moduleOffset = 528; + bytes.copy( + bytes, + versionOffset, + fixture.versionOffset, + fixture.versionOffset + 8, + ); + fixture.moduleBytes.copy(bytes, moduleOffset); + word(1536, 0x10000 + versionOffset); + word(1536 + width * 2, 0x10000 + moduleOffset); + return { ...fixture, versionOffset, moduleOffset }; +}; + +/** A pointer string or its two-word descriptor crossing adjacent ELF PT_LOADs. */ +export const createGoCrossLoadPointerFixture = ( + placement: "contents" | "descriptor", +) => { + const fixture = createGoBinaryFixture({ encoding: "pointer" }); + const bytes = Buffer.alloc(8192); + fixture.bytes.copy(bytes); + bytes.writeUInt16LE(2, 56); + bytes.writeBigUInt64LE(4096n, 160); + bytes.writeBigUInt64LE(4096n, 168); + bytes.writeBigUInt64LE(4096n, 176); + bytes.writeUInt32LE(1, 184); + bytes.writeUInt32LE(6, 188); + bytes.writeBigUInt64LE(4096n, 192); + bytes.writeBigUInt64LE(0x11000n, 200); + bytes.writeBigUInt64LE(4096n, 216); + bytes.writeBigUInt64LE(4096n, 224); + bytes.writeBigUInt64LE(4096n, 232); + bytes.writeBigUInt64LE(32n, 416); // Named discovery section contains only the record. + const moduleOffset = 5000; + fixture.moduleBytes.copy(bytes, moduleOffset); + bytes.writeBigUInt64LE(0x10000n + BigInt(moduleOffset), 1552); + if (placement === "descriptor") { + bytes.copy(bytes, 4088, 1536, 1552); + bytes.writeBigUInt64LE(0x10ff8n, fixture.headerOffset + 16); + return { ...fixture, bytes, moduleOffset }; + } + const versionOffset = 4092; + bytes.copy( + bytes, + versionOffset, + fixture.versionOffset, + fixture.versionOffset + 8, + ); + bytes.writeBigUInt64LE(0x10ffcn, 1536); + return { ...fixture, bytes, versionOffset, moduleOffset }; +}; + /** Build an ELF inline record with a selected aggregate string-content size. */ export const createGoInlineAggregateFixture = (contentBytes: number) => { const fixture = createGoBinaryFixture({ moduleText: "" });