From c19c2d6723fb92548b0c8a1ed0696d721a80aae6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:15 +0800 Subject: [PATCH] fix(native): separate container facts from Go metadata admission Move ELF, PE and Mach-O container parsing into a shared native owner. Go alone selects and validates Go build-info sections, so unrelated malformed Go metadata no longer blocks Rust dependency or IL2CPP inspection. Remove the superseded Go-specific container representation and migrate all consumers. Adapted from draft PR #5510 without asserting human PR readiness. Validation: 51 compiled CLI/MCP acceptance tests across Rust, Go and IL2CPP; 186 shared-reader tests; full check:ci passed. Three public synthetic-file probes reproduce the baseline Rust refusal and validate the fixed SDK-advertised MCP output, artifact digests and unchanged bytes. The exact zero-address nonallocated ELF case now succeeds for Rust while Go still reports invalid_format. No target execution or real compiler-artifact qualification was performed; no performance improvement is claimed. Closes #3398 --- src/go/GoBinaryImage.ts | 135 ++++++---------- src/go/GoBinaryProvider.ts | 12 +- src/go/GoBuildInfoSearch.ts | 137 ++++++++++++++++ src/go/GoMachoImage.alignment.test.ts | 2 +- src/il2cpp/ElfRelocations.ts | 20 +-- src/il2cpp/Il2cppBinaryImage.ts | 34 ++-- .../metadata/BinaryContainer.ts} | 123 ++++++++------ .../metadata/ElfImage.ts} | 151 +++++++----------- src/native/metadata/Il2cppContainer.test.ts | 50 ++++++ .../metadata/MachoImage.ts} | 137 +++++++--------- .../metadata/PeImage.test.ts} | 45 ++++-- .../metadata/PeImage.ts} | 112 +++++++------ src/native/metadata/readBinaryContainer.ts | 48 ++++++ src/rust/RustBinaryImage.ts | 26 +-- tests/acceptance/rustBinaryCli.test.ts | 94 +++++++++++ tests/boundary/go/goBinaryImage.test.ts | 48 +++++- tests/boundary/go/goBinaryMappings.test.ts | 2 +- tests/boundary/rust/rustBinaryImage.test.ts | 48 ++++++ tests/fixtures/rust/image.ts | 72 +++++++++ 19 files changed, 876 insertions(+), 420 deletions(-) create mode 100644 src/go/GoBuildInfoSearch.ts rename src/{go/GoBinaryContainer.ts => native/metadata/BinaryContainer.ts} (69%) rename src/{go/GoElfImage.ts => native/metadata/ElfImage.ts} (67%) create mode 100644 src/native/metadata/Il2cppContainer.test.ts rename src/{go/GoMachoImage.ts => native/metadata/MachoImage.ts} (68%) rename src/{go/GoPeImage.test.ts => native/metadata/PeImage.test.ts} (55%) rename src/{go/GoPeImage.ts => native/metadata/PeImage.ts} (70%) create mode 100644 src/native/metadata/readBinaryContainer.ts diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index c9e2366c8c..8519b97451 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -1,25 +1,14 @@ import { - GoBinaryFormatFailure, - GoBinaryResourceFailure, - GoBinaryReader, - goUtf8, + BinaryFormatFailure, + BinaryResourceFailure, + BinaryReader, mappedFileOffset, isZeroFilledSpan, - type GoBinaryContainer, - type GoFileMapping, -} from "./GoBinaryContainer.js"; -import { - classifyFatContainerIdentity, - readFatContainerHeader, -} from "../domain/apple/machoContainer.js"; -import { readGoElfImage } from "./GoElfImage.js"; -import { readGoMachoImage } from "./GoMachoImage.js"; -import { readGoPeImage } from "./GoPeImage.js"; - -export { - GoBinaryFormatFailure, - GoBinaryResourceFailure, -} from "./GoBinaryContainer.js"; + type BinaryContainer, + type FileMapping, +} from "../native/metadata/BinaryContainer.js"; +import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; +import { goBuildInfoSearch } from "./GoBuildInfoSearch.js"; const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex"); const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex"); @@ -56,19 +45,19 @@ export interface GoBinaryImage { /** Inspect bounded executable bytes using native mappings, without loading or executing code. */ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { - const container = readGoBinaryContainer(bytes); + const container = readBinaryContainer(bytes); const { format, architecture, bits, byte_order } = container; - let selected: { search: GoFileMapping; header: number } | null = null; + let selected: { search: FileMapping; header: number } | null = null; const searched = new Set(); let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES; - for (const search of container.search) { + for (const search of goBuildInfoSearch(bytes, container)) { // Identical file spans and alignment inspect the same candidate bytes. // Partially overlapping spans still consume a cumulative work budget. const identity = `${search.offset}:${search.size}:${search.address % 16n}`; if (searched.has(identity)) continue; searched.add(identity); if (search.size > remainingSearchBytes) - throw new GoBinaryResourceFailure( + throw new BinaryResourceFailure( "search", MAX_HEADER_SEARCH_BYTES, "Go build-info discovery exceeds the cumulative 256 MiB header-search budget", @@ -93,22 +82,22 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { container.zero_fills, ) !== header ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info bytes disagree with their virtual mapping", ); }; verifyMapping(32); - const reader = new GoBinaryReader(bytes, byte_order === "little"); + const reader = new BinaryReader(bytes, byte_order === "little"); const ptrSize = bytes[header + 14]; const flags = bytes[header + 15] ?? 0; if ((flags & ~3) !== 0) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "unsupported", "Go build-info flags contain an unsupported format bit", ); if ((flags & 2) === 0 && (flags & 1) !== (byte_order === "big" ? 1 : 0)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info byte order disagrees with the image", ); @@ -125,12 +114,12 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { verifyMapping(module.location.offset + module.location.bytes - header); } else { if (ptrSize !== 4 && ptrSize !== 8) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info pointer width must be 4 or 8", ); if (ptrSize * 8 !== bits) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info pointer width disagrees with the image", ); @@ -149,13 +138,13 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { ); } if (version.location.bytes + module.location.bytes > MAX_BUILD_INFO_BYTES) - throw new GoBinaryResourceFailure( + throw new BinaryResourceFailure( "build-info", MAX_BUILD_INFO_BYTES, "Go build-info strings exceed the aggregate 1 MiB limit", ); if (version.bytes.length === 0) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info compiler version is empty", ); @@ -168,7 +157,7 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { !module.bytes.subarray(-16).equals(MODULE_END) || module.bytes[module.bytes.length - 17] !== 10 ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go module string framing is invalid", ); @@ -194,9 +183,9 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { const observedText = (bytes: Buffer): string | null => { try { - return goUtf8(bytes, "Go build string"); + return observedUtf8(bytes, "Go build string"); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return null; + if (cause instanceof BinaryFormatFailure) return null; throw cause; } }; @@ -206,45 +195,7 @@ interface LocatedString { readonly location: GoStringLocation; } -/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */ -export const readGoBinaryContainer = (bytes: Buffer): GoBinaryContainer => { - if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) - return readGoElfImage(bytes); - if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) - return readGoPeImage(bytes); - if (bytes.length >= 4) { - const magic = bytes.readUInt32BE(); - if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) { - const fatHeader = readFatContainerHeader(bytes, bytes.length); - if (fatHeader.state === "malformed") - throw new GoBinaryFormatFailure("malformed", fatHeader.reason); - const identity = - fatHeader.state === "parsed" - ? classifyFatContainerIdentity(fatHeader, (offset) => { - if (offset >= bytes.length) return undefined; - if (offset + 4 > bytes.length) return null; - return bytes.readUInt32BE(offset); - }) - : undefined; - // An invalid FAT-shaped table is not proof of a Mach-O container: JVM - // constant-pool bytes can share both the magic and a known CPU value. - const confirmedFatMachO = identity?.state === "mach-o"; - if (confirmedFatMachO) - throw new GoBinaryFormatFailure( - "unsupported", - "Universal/fat Mach-O containers require selecting a thin slice", - ); - } - if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) - return readGoMachoImage(bytes); - } - throw new GoBinaryFormatFailure( - "unsupported", - "Only ELF, PE, and thin Mach-O native images are supported", - ); -}; - -const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => { +const findHeader = (bytes: Buffer, search: FileMapping): number | null => { if (search.size === 0) return null; const end = search.offset + search.size; const selected = bytes.subarray(search.offset, end); @@ -256,7 +207,7 @@ const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => { const address = search.address + BigInt(candidate - search.offset); if (address % 16n === 0n) { if (end - candidate < 32) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info header is truncated", ); @@ -268,34 +219,34 @@ const findHeader = (bytes: Buffer, search: GoFileMapping): number | null => { }; const readInlineString = ( - reader: GoBinaryReader, + reader: BinaryReader, offset: number, remaining: number, ): LocatedString => { let length = 0n; for (let index = 0; index < 10; index++) { if (index >= remaining) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info varint is truncated", ); const byte = reader.bytes[offset + index] ?? 0; if (index === 9 && byte > 1) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info varint overflows 64 bits", ); length |= BigInt(byte & 127) << BigInt(index * 7); if ((byte & 128) === 0) { if (length > BigInt(MAX_BUILD_INFO_BYTES)) - throw new GoBinaryResourceFailure( + throw new BinaryResourceFailure( "build-info", MAX_BUILD_INFO_BYTES, "Go build-info string length exceeds the 1 MiB limit", ); const size = Number(length); if (size > remaining - index - 1) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info string range is truncated", ); @@ -306,15 +257,15 @@ const readInlineString = ( }; } } - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Go build-info varint is unterminated", ); }; const readPointerString = ( - reader: GoBinaryReader, - container: GoBinaryContainer, + reader: BinaryReader, + container: BinaryContainer, address: bigint, ): LocatedString => { const { mappings, zero_fills: zeroFills, bits } = container; @@ -323,7 +274,7 @@ const readPointerString = ( const valueAddress = reader.word(header, bits); const length = reader.word(header + width, bits); if (length > BigInt(MAX_BUILD_INFO_BYTES)) - throw new GoBinaryResourceFailure( + throw new BinaryResourceFailure( "build-info", MAX_BUILD_INFO_BYTES, "Go build-info pointer string length exceeds the 1 MiB limit", @@ -341,8 +292,8 @@ const readPointerString = ( }; const readOptionalModuleString = ( - reader: GoBinaryReader, - container: GoBinaryContainer, + reader: BinaryReader, + container: BinaryContainer, address: bigint, pointerField: number, ): LocatedString => @@ -354,3 +305,17 @@ const readOptionalModuleString = ( ) ? { bytes: Buffer.alloc(0), location: { offset: pointerField, bytes: 0 } } : readPointerString(reader, container, address); + +/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */ +export const observedUtf8 = (bytes: Buffer, label: string): string => { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + } catch (cause) { + throw new BinaryFormatFailure( + "malformed", + `${label} contains malformed UTF-8: ${String(cause)}`, + ); + } +}; diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index 7bbeb5c1d5..9488f07d14 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -30,11 +30,11 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; +import { readGoBinaryImage } from "./GoBinaryImage.js"; import { - readGoBinaryImage, - GoBinaryFormatFailure, - GoBinaryResourceFailure, -} from "./GoBinaryImage.js"; + BinaryFormatFailure, + BinaryResourceFailure, +} from "../native/metadata/BinaryContainer.js"; import { parseGoModuleBytes, parseGoModuleText } from "./GoModuleText.js"; /** Bundled format reader identity; embedded compiler versions are separate observed facts. */ @@ -57,7 +57,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { return new AnalysisCancelledError(OPERATION, { cause: artifactFileCancellationCause(cause), }); - if (cause instanceof GoBinaryResourceFailure) + if (cause instanceof BinaryResourceFailure) return new AnalysisResourceConstraintError( OPERATION, cause.boundary === "search" ? "cpu" : "memory", @@ -75,7 +75,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => { ), }, ); - if (cause instanceof GoBinaryFormatFailure) + if (cause instanceof BinaryFormatFailure) return cause.kind === "unsupported" ? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, { cause, diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts new file mode 100644 index 0000000000..7227acb6f8 --- /dev/null +++ b/src/go/GoBuildInfoSearch.ts @@ -0,0 +1,137 @@ +import { + BinaryFormatFailure, + BinaryReader, + mappedFileOffset, + type BinaryContainer, + type FileMapping, +} from "../native/metadata/BinaryContainer.js"; + +const fallbackSearchRanges = ( + container: BinaryContainer, +): readonly FileMapping[] => { + if (container.format === "elf") + return (container.load_regions ?? []) + .filter((region) => region.write && !region.execute) + .map((region) => region.mapping); + if (container.format === "macho") + return (container.load_regions ?? []) + .filter( + (region) => + region.mapping.address !== 0n && + region.protection_masks?.current === 3 && + region.protection_masks.maximum === 3, + ) + .slice(0, 1) + .map((region) => region.mapping); + if (container.image_base === undefined) return []; + const section = container.sections.find( + (candidate) => + candidate.format === "pe" && + candidate.virtual_address !== 0 && + candidate.raw_size !== 0 && + (candidate.characteristics & ~0x00600000) >>> 0 === 0xc0000040, + ); + return section?.format === "pe" + ? [ + { + address: container.image_base + BigInt(section.virtual_address), + offset: section.offset, + size: section.size, + }, + ] + : []; +}; + +/** Go owns build-info section selection and validity; native readers only own container facts. */ +export const goBuildInfoSearch = ( + bytes: Buffer, + container: BinaryContainer, +): readonly FileMapping[] => { + const reader = new BinaryReader(bytes, container.byte_order === "little"); + const named: FileMapping[] = []; + for (const section of container.sections) { + if (section.format === "elf" && section.name === ".go.buildinfo") { + if (named.length !== 0) + throw new BinaryFormatFailure( + "malformed", + "ELF contains ambiguous build-info sections", + ); + if (section.type !== 1) + throw new BinaryFormatFailure( + "malformed", + "ELF build-info section is not file-backed data", + ); + if (section.compressed) + throw new BinaryFormatFailure( + "unsupported", + "Compressed ELF build-info sections are unsupported", + ); + const search = { + ...reader.fileRange( + section.declared_range.offset, + section.declared_range.size, + "ELF build-info section", + ), + address: section.address, + }; + if ( + mappedFileOffset( + container.mappings, + search.address, + search.size, + container.zero_fills, + ) !== search.offset + ) + throw new BinaryFormatFailure( + "malformed", + "ELF build-info section disagrees with its load mapping", + ); + named.push(search); + } else if ( + section.format === "macho" && + section.section_name === "__go_buildinfo" + ) { + if (section.segment_unmapped) + throw new BinaryFormatFailure( + "malformed", + "Mach-O build-info section is in an unmapped segment", + ); + if (section.segment_mapping === null) + throw new BinaryFormatFailure( + "malformed", + "Mach-O build-info section is not file mapped", + ); + if ([1, 0xc, 0x12].includes(section.type)) + throw new BinaryFormatFailure( + "malformed", + "Mach-O build-info section is zero-fill data", + ); + const search = { + ...reader.fileRange( + section.declared_range.offset, + section.declared_range.size, + "Mach-O build-info section", + ), + address: section.address, + }; + if ( + mappedFileOffset( + [section.segment_mapping], + search.address, + search.size, + ) !== search.offset + ) + throw new BinaryFormatFailure( + "malformed", + "Mach-O build-info section disagrees with its segment", + ); + named.push(search); + } + } + if (named.length > 1) + throw new BinaryFormatFailure( + "malformed", + "Mach-O has ambiguous build-info sections", + ); + return named.length > 0 ? named : fallbackSearchRanges(container); +}; diff --git a/src/go/GoMachoImage.alignment.test.ts b/src/go/GoMachoImage.alignment.test.ts index 07a378a86b..a2ad09b488 100644 --- a/src/go/GoMachoImage.alignment.test.ts +++ b/src/go/GoMachoImage.alignment.test.ts @@ -7,7 +7,7 @@ it("rejects a 12-byte load command in a 64-bit Go Mach-O image", () => { readGoBinaryImage(Buffer.from(machoImageWithUnknownCommand(true, 12))), ).toThrow( expect.objectContaining({ - name: "GoBinaryFormatFailure", + name: "BinaryFormatFailure", kind: "malformed", }), ); diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts index f06a846d47..ed8b540093 100644 --- a/src/il2cpp/ElfRelocations.ts +++ b/src/il2cpp/ElfRelocations.ts @@ -1,10 +1,10 @@ import { - GoBinaryFormatFailure, + BinaryFormatFailure, mappedFileOffset, isZeroFilledSpan, - type GoFileMapping, - type GoZeroFillMapping, -} from "../go/GoBinaryContainer.js"; + type FileMapping, + type ZeroFillMapping, +} from "../native/metadata/BinaryContainer.js"; import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; const DT_RELA = 7n, @@ -26,8 +26,8 @@ interface RelocatedImage { } interface RelocationSource { readonly image: Buffer; - readonly mappings: readonly GoFileMapping[]; - readonly zeroFills: readonly GoZeroFillMapping[]; + readonly mappings: readonly FileMapping[]; + readonly zeroFills: readonly ZeroFillMapping[]; readonly relative: number; readonly unknownPointerKinds: ReadonlySet; } @@ -101,14 +101,14 @@ function* dynamicTags(image: Buffer): Generator> { } const fileOffset = ( - mappings: readonly GoFileMapping[], + mappings: readonly FileMapping[], address: bigint, size: number, ): number | null => { try { return mappedFileOffset(mappings, address, size); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return null; + if (cause instanceof BinaryFormatFailure) return null; throw cause; } }; @@ -178,8 +178,8 @@ function* applyRelaTable( /** Materialize relative pointers in a private copy, preserving unknown and .bss facts. */ export function* applyElfRelativeRelocationsSteps( image: Buffer, - mappings: readonly GoFileMapping[], - zeroFills: readonly GoZeroFillMapping[], + mappings: readonly FileMapping[], + zeroFills: readonly ZeroFillMapping[], ): Generator { if (image.length < 64) throw malformed("ELF header is truncated"); const kinds = RELOCATION_KINDS.get(image.readUInt16LE(18)); diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index 9c14aa08b2..9540da86b1 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -3,13 +3,13 @@ import { validateFatSlice, } from "../domain/apple/machoContainer.js"; import { - GoBinaryFormatFailure, - GoBinaryResourceFailure, + BinaryFormatFailure, + BinaryResourceFailure, mappedFileOffset, isZeroFilledSpan, - type GoBinaryContainer, -} from "../go/GoBinaryContainer.js"; -import { readGoBinaryContainer } from "../go/GoBinaryImage.js"; + type BinaryContainer, +} from "../native/metadata/BinaryContainer.js"; +import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js"; import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; const MACHO_CPU = new Map([ @@ -64,7 +64,7 @@ const selectImage = ( export interface NativeImage { readonly bytes: Buffer; - readonly container: GoBinaryContainer; + readonly container: BinaryContainer; readonly imageBase: bigint; readonly unresolvedPointerSlots: ReadonlySet; readonly zeroFillPointers: ReadonlyMap; @@ -75,16 +75,16 @@ export function* openImageSteps( architecture: string | undefined, ): Generator { let image = selectImage(bytes, architecture); - let container: GoBinaryContainer; + let container: BinaryContainer; try { - container = readGoBinaryContainer(image); + container = readBinaryContainer(image); } catch (cause) { - if (cause instanceof GoBinaryResourceFailure) + if (cause instanceof BinaryResourceFailure) throw new Il2cppBinaryFormatFailure( "native-index-capacity", cause.message, ); - if (cause instanceof GoBinaryFormatFailure) + if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure( cause.kind === "unsupported" ? "unsupported-container" @@ -121,7 +121,7 @@ export function* openImageSteps( container.zero_fills, ); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) + if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); throw cause; } @@ -172,7 +172,7 @@ export const readPointer = ( ); return image.bytes.readBigUInt64LE(offset); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return null; + if (cause instanceof BinaryFormatFailure) return null; throw cause; } }; @@ -195,7 +195,7 @@ export const readUInt32 = ( ), ); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return null; + if (cause instanceof BinaryFormatFailure) return null; throw cause; } }; @@ -205,7 +205,7 @@ export const isMapped = (image: NativeImage, address: bigint, size: number) => { mappedFileOffset(image.container.mappings, address, size); return true; } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return false; + if (cause instanceof BinaryFormatFailure) return false; throw cause; } }; @@ -262,7 +262,7 @@ export const hasPointerArrayCoverage = ( ); } } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return false; + if (cause instanceof BinaryFormatFailure) return false; throw cause; } cursor += BigInt(size); @@ -284,7 +284,7 @@ export const isExecutable = (image: NativeImage, address: bigint): boolean => { mappedFileOffset(image.container.executable_mappings ?? [], address, 1) ); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) return false; + if (cause instanceof BinaryFormatFailure) return false; throw cause; } }; @@ -312,7 +312,7 @@ export const readSlot = ( try { return isZeroFilled(image, address, 8) ? 0n : readPointer(image, address); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) + if (cause instanceof BinaryFormatFailure) throw new Il2cppBinaryFormatFailure("malformed-container", cause.message); throw cause; } diff --git a/src/go/GoBinaryContainer.ts b/src/native/metadata/BinaryContainer.ts similarity index 69% rename from src/go/GoBinaryContainer.ts rename to src/native/metadata/BinaryContainer.ts index 354a04c472..6d0766be20 100644 --- a/src/go/GoBinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -1,69 +1,112 @@ /** Distinguish corrupt recognized binaries from containers outside parser coverage. */ -export class GoBinaryFormatFailure extends Error { +export class BinaryFormatFailure extends Error { constructor( readonly kind: "malformed" | "unsupported", message: string, ) { super(message); - this.name = "GoBinaryFormatFailure"; + this.name = "BinaryFormatFailure"; } } /** Resource boundary exceeded while decoding metadata or native container structure. */ -export class GoBinaryResourceFailure extends Error { +export class BinaryResourceFailure extends Error { constructor( readonly boundary: "build-info" | "structure" | "search", readonly maximum_bytes: number, message: string, ) { super(message); - this.name = "GoBinaryResourceFailure"; + this.name = "BinaryResourceFailure"; } } /** One file-backed virtual range; addresses stay exact until mapped to file offsets. */ -export interface GoFileMapping { +export interface FileMapping { readonly address: bigint; readonly offset: number; readonly size: number; } /** Declared zero-initialized virtual memory with no corresponding file bytes. */ -export interface GoZeroFillMapping { +export interface ZeroFillMapping { readonly address: bigint; readonly size: bigint; } -/** One named file section. Invalid declared ranges stay visible with `file_backed: false`. */ -export interface GoImageSection { +/** Native loader region facts; language-specific fallback selection belongs to consumers. */ +export interface LoadRegion { + readonly mapping: FileMapping; + readonly memory_size: bigint; + readonly read: boolean; + readonly write: boolean; + readonly execute: boolean; + /** Raw Mach-O VM_PROT masks preserve bits not represented by booleans. */ + readonly protection_masks?: { + readonly current: number; + readonly maximum: number; + }; +} + +/** Parsed section facts; consumer metadata validity is separate from native structure. */ +export type ImageSection = { readonly name: string; readonly offset: number; readonly size: number; readonly file_backed: boolean; readonly read_only: boolean; readonly compressed: boolean; -} +} & ( + | { + readonly format: "elf"; + readonly type: number; + readonly address: bigint; + readonly declared_range: { + readonly offset: bigint; + readonly size: bigint; + }; + } + | { + readonly format: "pe"; + readonly virtual_address: number; + readonly virtual_size: number; + readonly raw_size: number; + readonly characteristics: number; + } + | { + readonly format: "macho"; + readonly section_name: string; + readonly type: number; + readonly address: bigint; + readonly declared_range: { + readonly offset: bigint; + readonly size: bigint; + }; + readonly segment_mapping: FileMapping | null; + readonly segment_unmapped: boolean; + } +); -/** Native container facts and its ordered producer-defined build-info search ranges. */ -export interface GoBinaryContainer { +/** Native container facts, without language metadata search policy. */ +export interface BinaryContainer { readonly format: "elf" | "pe" | "macho"; readonly architecture: string; readonly bits: 32 | 64; readonly byte_order: "little" | "big"; - /** Declared preferred base when the container supplies one (PE). */ + /** Declared PE preferred base, when available. */ readonly image_base?: bigint; - /** Mach-O load-command observation; omitted for other formats. */ + /** Mach-O load-command observation, when available. */ readonly macho_chained_fixups?: boolean; - readonly mappings: readonly GoFileMapping[]; - /** File-backed ranges the native loader marks executable; omitted if unobserved. */ - readonly executable_mappings?: readonly GoFileMapping[]; - readonly zero_fills: readonly GoZeroFillMapping[]; - readonly sections: readonly GoImageSection[]; - readonly search: readonly GoFileMapping[]; + readonly mappings: readonly FileMapping[]; + readonly load_regions?: readonly LoadRegion[]; + /** File-backed ranges the native loader marks executable. */ + readonly executable_mappings?: readonly FileMapping[]; + readonly zero_fills: readonly ZeroFillMapping[]; + readonly sections: readonly ImageSection[]; } /** Reader that checks every structural range before decoding numeric fields. */ -export class GoBinaryReader { +export class BinaryReader { constructor( readonly bytes: Buffer, readonly little: boolean, @@ -78,7 +121,7 @@ export class GoBinaryReader { offset > this.bytes.length || size > this.bytes.length - offset ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", `${label} range is truncated or outside the file`, ); @@ -101,12 +144,12 @@ export class GoBinaryReader { return this.little ? value.readBigUInt64LE() : value.readBigUInt64BE(); } - fileRange(offset: bigint, size: bigint, label: string): GoFileMapping { + fileRange(offset: bigint, size: bigint, label: string): FileMapping { if ( offset > BigInt(this.bytes.length) || size > BigInt(this.bytes.length) - offset ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", `${label} range is outside the file`, ); @@ -118,7 +161,7 @@ export class GoBinaryReader { table(offset: bigint, count: number, width: number, label: string): number { const size = BigInt(count) * BigInt(width); if (size > 16n * 1024n * 1024n) - throw new GoBinaryResourceFailure( + throw new BinaryResourceFailure( "structure", 16 * 1024 * 1024, `${label} exceeds the 16 MiB structural decoding budget`, @@ -155,10 +198,10 @@ export const declaredFileRange = ( /** Map a virtual span without losing precision or selecting an ambiguous alias. */ export const mappedFileOffset = ( - mappings: readonly GoFileMapping[], + mappings: readonly FileMapping[], address: bigint, size: number, - zeroFills: readonly GoZeroFillMapping[] = [], + zeroFills: readonly ZeroFillMapping[] = [], ): number => { let found: number | undefined; for (const mapping of mappings) { @@ -171,20 +214,20 @@ export const mappedFileOffset = ( continue; const offset = mapping.offset + Number(delta); if (found !== undefined && found !== offset) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Virtual address has ambiguous file mappings", ); found = offset; } if (found === undefined) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "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 GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Virtual address has ambiguous file and zero-fill mappings", ); @@ -199,7 +242,7 @@ export const mappedFileOffset = ( BigInt(found) + start - address !== BigInt(mapping.offset) + start - mapping.address ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Virtual address has ambiguous file mappings", ); @@ -209,8 +252,8 @@ export const mappedFileOffset = ( /** Recognize a wholly zero-filled span while refusing even partial file aliases. */ export const isZeroFilledSpan = ( - mappings: readonly GoFileMapping[], - zeroFills: readonly GoZeroFillMapping[], + mappings: readonly FileMapping[], + zeroFills: readonly ZeroFillMapping[], address: bigint, size: number, ): boolean => { @@ -225,7 +268,7 @@ export const isZeroFilledSpan = ( return false; for (const mapping of mappings) { if (spansOverlap(address, bytes, mapping.address, BigInt(mapping.size))) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Virtual address has ambiguous file and zero-fill mappings", ); @@ -243,17 +286,3 @@ const spansOverlap = ( otherSize > 0n && address < otherAddress + otherSize && otherAddress < address + size; - -/** Decode metadata text without replacing invalid bytes or stripping an initial BOM. */ -export const goUtf8 = (bytes: Buffer, label: string): string => { - try { - return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes, - ); - } catch (cause) { - throw new GoBinaryFormatFailure( - "malformed", - `${label} contains malformed UTF-8: ${String(cause)}`, - ); - } -}; diff --git a/src/go/GoElfImage.ts b/src/native/metadata/ElfImage.ts similarity index 67% rename from src/go/GoElfImage.ts rename to src/native/metadata/ElfImage.ts index a53fbae62a..71e9f99f3c 100644 --- a/src/go/GoElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -1,22 +1,22 @@ import { declaredFileRange, - GoBinaryFormatFailure, - GoBinaryReader, - mappedFileOffset, - type GoBinaryContainer, - type GoFileMapping, - type GoImageSection, - type GoZeroFillMapping, -} from "./GoBinaryContainer.js"; -import { readElfFixedHeader } from "../domain/elfFixedHeader.js"; + BinaryFormatFailure, + BinaryReader, + type BinaryContainer, + type FileMapping, + type ImageSection, + type LoadRegion, + type ZeroFillMapping, +} from "./BinaryContainer.js"; +import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; -/** Read ELF build-info sections and file-backed program mappings. */ -export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { +/** Read ELF sections and file-backed program mappings without interpreting language metadata. */ +export const readElfImage = (bytes: Buffer): BinaryContainer => { const fixedHeader = readElfFixedHeader(bytes); if (!fixedHeader.ok) - throw new GoBinaryFormatFailure("malformed", fixedHeader.error); + throw new BinaryFormatFailure("malformed", fixedHeader.error); const { elfClass: bits, headerSize } = fixedHeader.value; - const reader = new GoBinaryReader( + const reader = new BinaryReader( bytes, fixedHeader.value.byteOrder === "little", ); @@ -32,7 +32,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { (phCount !== 0 && phOffset < BigInt(headerSize)) || (shOffset !== 0n && shOffset < BigInt(headerSize)) ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF header table overlaps the native header", ); @@ -41,7 +41,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { (shCount === 0 || phCount === 0xffff || namesIndex === 0xffff) ) { if (shWidth < (wide ? 64 : 40)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF extended section header width is invalid", ); @@ -51,14 +51,14 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { "ELF section zero", ).offset; if (reader.u32(first + 4) !== 0) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF initial section for extended numbering must have SHT_NULL type", ); if (shCount === 0) { const count = reader.word(first + (wide ? 32 : 20), bits); if (count < 0xff00n || count > BigInt(Number.MAX_SAFE_INTEGER)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF extended section count is invalid", ); @@ -67,7 +67,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { if (phCount === 0xffff) { phCount = reader.u32(first + (wide ? 44 : 28)); if (phCount < 0xffff) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF extended program count is below its reserved range", ); @@ -75,7 +75,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { if (namesIndex === 0xffff) { namesIndex = reader.u32(first + (wide ? 40 : 24)); if (namesIndex < 0xff00) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF extended section name index is below its reserved range", ); @@ -84,13 +84,10 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { shOffset === 0n && (shCount !== 0 || namesIndex !== 0 || phCount === 0xffff) ) { - throw new GoBinaryFormatFailure( - "malformed", - "ELF section table is missing", - ); + throw new BinaryFormatFailure("malformed", "ELF section table is missing"); } if (phCount !== 0 && phWidth < (wide ? 56 : 32)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF program header width is invalid", ); @@ -100,11 +97,11 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { phWidth, "ELF program headers", ); - const mappings: GoFileMapping[] = []; - const executableMappings: GoFileMapping[] = []; - const zeroFills: GoZeroFillMapping[] = []; - const sections: GoImageSection[] = []; - const fallback: GoFileMapping[] = []; + const mappings: FileMapping[] = []; + const loadRegions: LoadRegion[] = []; + const executableMappings: FileMapping[] = []; + const zeroFills: ZeroFillMapping[] = []; + const sections: ImageSection[] = []; for (let index = 0; index < phCount; index++) { const offset = phStart + index * phWidth; if (reader.u32(offset) !== 1) continue; @@ -114,7 +111,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { const size = reader.word(offset + (wide ? 32 : 16), bits); const memorySize = reader.word(offset + (wide ? 40 : 20), bits); if (size > memorySize || address + memorySize > 1n << BigInt(bits)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF load segment sizes or addresses are invalid", ); @@ -126,16 +123,21 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { }; if (mapping.size !== 0) { mappings.push(mapping); + loadRegions.push({ + mapping, + memory_size: memorySize, + read: (flags & 4) !== 0, + write: (flags & 2) !== 0, + execute: (flags & 1) !== 0, + }); if ((flags & 1) !== 0) executableMappings.push(mapping); } if (memorySize > size) zeroFills.push({ address: address + size, size: memorySize - size }); - if (mapping.size !== 0 && (flags & 3) === 2) fallback.push(mapping); } - let search: GoFileMapping | null = null; if (shCount !== 0) { if (shWidth < (wide ? 64 : 40) || namesIndex >= shCount) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF section width or name-table index is invalid", ); @@ -148,7 +150,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { if (namesIndex !== 0) { const namesHeader = shStart + namesIndex * shWidth; if (reader.u32(namesHeader + 4) !== 3) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF section name table is not a string table", ); @@ -161,7 +163,7 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { const offset = shStart + index * shWidth; const nameOffset = reader.u32(offset); if (nameOffset >= names.size) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "ELF section name offset is outside its string table", ); @@ -174,73 +176,29 @@ export const readGoElfImage = (bytes: Buffer): GoBinaryContainer => { wide, header: offset, }); - // Comparing the complete fixed name avoids quadratic scans through malicious string tables. - const expectedName = Buffer.from(".go.buildinfo\0"); - if ( - expectedName.length > names.size - nameOffset || - !bytes - .subarray( - names.offset + nameOffset, - names.offset + nameOffset + expectedName.length, - ) - .equals(expectedName) - ) - continue; - if (search !== null) - throw new GoBinaryFormatFailure( - "malformed", - "ELF contains ambiguous build-info sections", - ); - if (reader.u32(offset + 4) !== 1) - throw new GoBinaryFormatFailure( - "malformed", - "ELF build-info section is not file-backed data", - ); - if ((reader.word(offset + 8, bits) & 0x800n) !== 0n) - throw new GoBinaryFormatFailure( - "unsupported", - "Compressed ELF build-info sections are unsupported", - ); - const address = reader.word(offset + (wide ? 16 : 12), bits); - search = { - ...reader.fileRange( - reader.word(offset + (wide ? 24 : 16), bits), - reader.word(offset + (wide ? 32 : 20), bits), - "ELF build-info section", - ), - address, - }; - if ( - mappedFileOffset(mappings, address, search.size, zeroFills) !== - search.offset - ) - throw new GoBinaryFormatFailure( - "malformed", - "ELF build-info section disagrees with its load mapping", - ); } } } return { format: "elf", - architecture: goArchitectureForElfMachine(fixedHeader.value.machine), + architecture: architectureForElfMachine(fixedHeader.value.machine), bits, byte_order: reader.little ? "little" : "big", mappings, + load_regions: loadRegions, executable_mappings: executableMappings, zero_fills: zeroFills, sections, - search: search === null ? fallback : [search], }; }; const recordElfSection = ( - sections: GoImageSection[], + sections: ImageSection[], input: { readonly bytes: Buffer; readonly names: { readonly offset: number; readonly size: number }; readonly nameOffset: number; - readonly reader: GoBinaryReader; + readonly reader: BinaryReader; readonly bits: 32 | 64; readonly wide: boolean; readonly header: number; @@ -299,18 +257,22 @@ const elfImageSection = ({ header, name, }: { - readonly reader: GoBinaryReader; + readonly reader: BinaryReader; readonly bits: 32 | 64; readonly wide: boolean; readonly header: number; readonly name: string; -}): GoImageSection => { +}): ImageSection => { const type = reader.u32(header + 4); const flags = reader.word(header + 8, bits); + const declared_range = { + offset: reader.word(header + (wide ? 24 : 16), bits), + size: reader.word(header + (wide ? 32 : 20), bits), + }; const declared = declaredFileRange( reader.bytes.length, - reader.word(header + (wide ? 24 : 16), bits), - reader.word(header + (wide ? 32 : 20), bits), + declared_range.offset, + declared_range.size, ); // SHT_NOBITS offsets and sizes do not describe stored bytes. const range = @@ -323,13 +285,22 @@ const elfImageSection = ({ (flags & 1n) === 0n && (flags & 4n) === 0n && (flags & 2n) !== 0n; - return { name, ...range, read_only: readOnly, compressed }; + return { + format: "elf", + name, + type, + address: reader.word(header + (wide ? 16 : 12), bits), + declared_range, + ...range, + read_only: readOnly, + compressed, + }; }; const SHT_NOBITS = 8; -/** Go build-info vocabulary, including families outside REA target admission. */ -const goArchitectureForElfMachine = (machine: number): string => { +/** Map ELF machine identifiers to stable architecture names. */ +const architectureForElfMachine = (machine: number): string => { const names = new Map([ [3, "386"], [8, "mips"], diff --git a/src/native/metadata/Il2cppContainer.test.ts b/src/native/metadata/Il2cppContainer.test.ts new file mode 100644 index 0000000000..a9bea21e3c --- /dev/null +++ b/src/native/metadata/Il2cppContainer.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from "vitest"; +import { readGoBinaryImage } from "../../go/GoBinaryImage.js"; +import { createRustImageWithGoSection } from "../../../tests/fixtures/rust/image.js"; +import { openImageSteps } from "../../il2cpp/Il2cppBinaryImage.js"; +import { readBinaryContainer } from "./readBinaryContainer.js"; + +it.each(["unmapped", "zero-fill", "duplicate", "out-of-file"] as const)( + "keeps IL2CPP admission independent of a Mach-O Go build-info %s failure", + (fault) => { + const goNamed = createRustImageWithGoSection("macho", fault); + const control = Buffer.from(goNamed); + const sectionName = Buffer.from("__other\0".padEnd(16, "\0"), "ascii"); + for (let index = 0; index < (fault === "duplicate" ? 2 : 1); index++) + sectionName.copy(control, 408 + index * 152); + + expect(() => readGoBinaryImage(goNamed)).toThrow(/build-info|mapped/i); + expect(readGoBinaryImage(control).build_info).toBeNull(); + expect(readBinaryContainer(goNamed).load_regions).toEqual( + readBinaryContainer(control).load_regions, + ); + + const image = openImageSteps(goNamed, undefined).next(); + expect(image.done).toBe(true); + if (!image.done) + throw new Error("container admission unexpectedly yielded"); + expect(image.value.bytes).toBe(goNamed); + expect(image.value.container).toMatchObject({ + format: "macho", + macho_chained_fixups: false, + }); + expect(image.value.container.executable_mappings?.length).toBeGreaterThan( + 0, + ); + expect( + image.value.container.sections.filter( + (section) => + section.format === "macho" && + section.section_name === "__go_buildinfo", + ), + ).toHaveLength(fault === "duplicate" ? 2 : 1); + + const controlImage = openImageSteps(control, undefined).next(); + expect(controlImage.done).toBe(true); + if (!controlImage.done) + throw new Error("control container admission unexpectedly yielded"); + expect(controlImage.value.container.mappings).toEqual( + image.value.container.mappings, + ); + }, +); diff --git a/src/go/GoMachoImage.ts b/src/native/metadata/MachoImage.ts similarity index 68% rename from src/go/GoMachoImage.ts rename to src/native/metadata/MachoImage.ts index ee5e000848..3e3c41d071 100644 --- a/src/go/GoMachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -1,21 +1,21 @@ import { declaredFileRange, - GoBinaryFormatFailure, - GoBinaryReader, - mappedFileOffset, - type GoBinaryContainer, - type GoFileMapping, - type GoImageSection, - type GoZeroFillMapping, -} from "./GoBinaryContainer.js"; -import { machoLoadCommandAlignment } from "../domain/apple/machoContainer.js"; + BinaryFormatFailure, + BinaryReader, + type BinaryContainer, + type FileMapping, + type ImageSection, + type LoadRegion, + type ZeroFillMapping, +} from "./BinaryContainer.js"; +import { machoLoadCommandAlignment } from "../../domain/apple/machoContainer.js"; /** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */ -export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { +export const readMachoImage = (bytes: Buffer): BinaryContainer => { const magic = bytes.readUInt32BE(0); const little = magic === 0xcefaedfe || magic === 0xcffaedfe; const bits = magic === 0xfeedfacf || magic === 0xcffaedfe ? 64 : 32; - const reader = new GoBinaryReader(bytes, little); + const reader = new BinaryReader(bytes, little); const headerSize = bits === 64 ? 32 : 28; reader.range(0, headerSize, "Mach-O header"); const count = reader.u32(16); @@ -27,18 +27,17 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { "Mach-O load commands", ); if (count > Math.floor(commandBytes / 8)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O command count exceeds its load-command range", ); - const mappings: GoFileMapping[] = []; - const executableMappings: GoFileMapping[] = []; - const zeroFills: GoZeroFillMapping[] = []; - const sections: GoImageSection[] = []; - const named: GoFileMapping[] = []; - let fallback: GoFileMapping | null = null; - let offset = commandStart; + const mappings: FileMapping[] = []; + const loadRegions: LoadRegion[] = []; + const executableMappings: FileMapping[] = []; + const zeroFills: ZeroFillMapping[] = []; + const sections: ImageSection[] = []; let chainedFixups = false; + let offset = commandStart; const end = commandStart + commandBytes; for (let index = 0; index < count; index++) { const command = reader.u32(offset); @@ -48,7 +47,7 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { size % machoLoadCommandAlignment(bits === 64) !== 0 || size > end - offset ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O load command size is invalid or truncated", ); @@ -56,14 +55,14 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { if (command === 1 || command === 0x19) { const wide = command === 0x19; if (wide !== (bits === 64)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O segment command does not match the header width", ); const segmentHeader = wide ? 72 : 56; const sectionWidth = wide ? 80 : 68; if (size < segmentHeader) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O segment header is truncated", ); @@ -79,12 +78,12 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { (!unmapped && fileSize > memorySize) || address + memorySize > 1n << BigInt(bits) ) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O segment addresses or sizes are invalid", ); if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O section table exceeds its segment command", ); @@ -101,6 +100,17 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { ); if (mapping !== undefined && name !== "__PAGEZERO") { mappings.push(mapping); + loadRegions.push({ + mapping, + memory_size: memorySize, + read: (protection & 1) !== 0, + write: (protection & 2) !== 0, + execute: (protection & 4) !== 0, + protection_masks: { + current: protection, + maximum: maxProtection, + }, + }); if ((protection & 4) !== 0) executableMappings.push(mapping); } if (memorySize > fileSize && name !== "__PAGEZERO") @@ -108,14 +118,6 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { address: address + fileSize, size: memorySize - fileSize, }); - if ( - fallback === null && - mapping !== undefined && - address !== 0n && - protection === 3 && - maxProtection === 3 - ) - fallback = mapping; for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) { const section = offset + segmentHeader + sectionIndex * sectionWidth; const sectionName = fixedName( @@ -129,6 +131,9 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { header: section, name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`, protection, + sectionName, + segmentMapping: mapping ?? null, + segmentUnmapped: unmapped, unmapped: unmapped || mapping === undefined, segmentAddress: address, segmentMemorySize: memorySize, @@ -136,56 +141,15 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { segmentFileSize: fileSize, }), ); - if (sectionName !== "__go_buildinfo") continue; - if (unmapped) - throw new GoBinaryFormatFailure( - "malformed", - "Mach-O build-info section is in an unmapped segment", - ); - if (mapping === undefined) - throw new GoBinaryFormatFailure( - "malformed", - "Mach-O build-info section is not file mapped", - ); - const sectionFlags = reader.u32(section + (wide ? 64 : 56)); - if ([1, 0xc, 0x12].includes(sectionFlags & 0xff)) - throw new GoBinaryFormatFailure( - "malformed", - "Mach-O build-info section is zero-fill data", - ); - const sectionAddress = reader.word(section + 32, bits); - const sectionSize = reader.word(section + (wide ? 40 : 36), bits); - const sectionOffset = BigInt(reader.u32(section + (wide ? 48 : 40))); - const info = { - ...reader.fileRange( - sectionOffset, - sectionSize, - "Mach-O build-info section", - ), - address: sectionAddress, - }; - if ( - mappedFileOffset([mapping], sectionAddress, info.size) !== info.offset - ) - throw new GoBinaryFormatFailure( - "malformed", - "Mach-O build-info section disagrees with its segment", - ); - named.push(info); } } offset += size; } if (offset !== end) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "Mach-O load-command byte count does not match its commands", ); - if (named.length > 1) - throw new GoBinaryFormatFailure( - "malformed", - "Mach-O has ambiguous build-info sections", - ); const cpu = reader.u32(4); const names = new Map([ [7, "386"], @@ -202,10 +166,10 @@ export const readGoMachoImage = (bytes: Buffer): GoBinaryContainer => { bits, byte_order: little ? "little" : "big", mappings, + load_regions: loadRegions, executable_mappings: executableMappings, zero_fills: zeroFills, sections, - search: named.length > 0 ? named : fallback === null ? [] : [fallback], }; }; @@ -216,24 +180,30 @@ const machoImageSection = ({ header, name, protection, + sectionName, + segmentMapping, + segmentUnmapped, unmapped, segmentAddress, segmentMemorySize, segmentFileOffset, segmentFileSize, }: { - readonly reader: GoBinaryReader; + readonly reader: BinaryReader; readonly bits: 32 | 64; readonly wide: boolean; readonly header: number; readonly name: string; readonly protection: number; + readonly sectionName: string; + readonly segmentMapping: FileMapping | null; + readonly segmentUnmapped: boolean; readonly unmapped: boolean; readonly segmentAddress: bigint; readonly segmentMemorySize: bigint; readonly segmentFileOffset: bigint; readonly segmentFileSize: bigint; -}): GoImageSection => { +}): ImageSection => { const flags = reader.u32(header + (wide ? 64 : 56)); const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff); const sectionAddress = reader.word(header + 32, bits); @@ -251,11 +221,7 @@ const machoImageSection = ({ sectionSize <= segmentFileSize - fileDelta; const range = unmapped ? { offset: 0, size: 0, file_backed: false } - : declaredFileRange( - reader.bytes.length, - BigInt(reader.u32(header + (wide ? 48 : 40))), - reader.word(header + (wide ? 40 : 36), bits), - ); + : declaredFileRange(reader.bytes.length, sectionOffset, sectionSize); const fileBacked = range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile; const readOnly = @@ -264,7 +230,14 @@ const machoImageSection = ({ (protection & 2) === 0 && (flags & 0x80000400) === 0; return { + format: "macho", name, + section_name: sectionName, + type: flags & 0xff, + address: sectionAddress, + declared_range: { offset: sectionOffset, size: sectionSize }, + segment_mapping: segmentMapping, + segment_unmapped: segmentUnmapped, ...range, file_backed: fileBacked, read_only: readOnly, diff --git a/src/go/GoPeImage.test.ts b/src/native/metadata/PeImage.test.ts similarity index 55% rename from src/go/GoPeImage.test.ts rename to src/native/metadata/PeImage.test.ts index 6119176c7c..70f33dfb34 100644 --- a/src/go/GoPeImage.test.ts +++ b/src/native/metadata/PeImage.test.ts @@ -1,10 +1,10 @@ import { expect, it } from "vitest"; -import { readGoBinaryImage } from "./GoBinaryImage.js"; -import { readGoPeImage } from "./GoPeImage.js"; +import { readGoBinaryImage } from "../../go/GoBinaryImage.js"; +import { readPeImage } from "./PeImage.js"; import { createGoBinaryFixture, setGoPeVirtualSize, -} from "../../tests/fixtures/go/image.js"; +} from "../../../tests/fixtures/go/image.js"; it.each([32, 64] as const)( "uses raw-size fallback for PE%d Go build-info and keeps RVA mappings intact", @@ -15,7 +15,7 @@ it.each([32, 64] as const)( expect(expected).not.toBeNull(); setGoPeVirtualSize(fixture.bytes, 0); - const container = readGoPeImage(fixture.bytes); + const container = readPeImage(fixture.bytes); expect(container.sections[0]).toMatchObject({ name: ".data", offset: 1024, @@ -27,16 +27,35 @@ it.each([32, 64] as const)( offset: 1024, size: 2048, }); - expect(container.search[0]).toMatchObject({ - address: 0x10400n, - offset: 1024, - size: 2048, + expect(container.sections[0]).toMatchObject({ + virtual_address: 0x400, + virtual_size: 0, + raw_size: 2048, + characteristics: 0xc0000040, }); + expect(container).not.toHaveProperty("data_ranges"); + expect(container.image_base).toBe(0x10000n); + expect(container.executable_mappings).toEqual([]); expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected); } }, ); +it("keeps Go fallback candidates when a PE section has no printable name", () => { + const fixture = createGoBinaryFixture({ format: "pe", bits: 64 }); + const expected = readGoBinaryImage(fixture.bytes).build_info; + const pe = fixture.bytes.readUInt32LE(0x3c); + const section = pe + 24 + fixture.bytes.readUInt16LE(pe + 20); + fixture.bytes.fill(0, section, section + 8); + + const container = readPeImage(fixture.bytes); + expect(container.sections[0]).toMatchObject({ + name: "", + characteristics: 0xc0000040, + }); + expect(readGoBinaryImage(fixture.bytes).build_info).toEqual(expected); +}); + it.each(["inline", "pointer"] as const)( "does not search PE raw alignment padding beyond a nonzero VirtualSize (%s)", (encoding) => { @@ -51,13 +70,19 @@ it.each(["inline", "pointer"] as const)( fixture.bytes.fill(0, fixture.headerOffset, fixture.headerOffset + 512); setGoPeVirtualSize(fixture.bytes, 512); - const container = readGoPeImage(fixture.bytes); + const container = readPeImage(fixture.bytes); expect(container.sections[0]).toMatchObject({ size: 512, file_backed: true, }); expect(container.mappings[0]).toMatchObject({ size: 2048, offset: 1024 }); - expect(container.search[0]).toMatchObject({ size: 512, offset: 1024 }); + expect(container.sections[0]).toMatchObject({ + size: 512, + offset: 1024, + virtual_size: 512, + raw_size: 2048, + }); + expect(container).not.toHaveProperty("data_ranges"); expect(readGoBinaryImage(fixture.bytes).build_info).toBeNull(); }, ); diff --git a/src/go/GoPeImage.ts b/src/native/metadata/PeImage.ts similarity index 70% rename from src/go/GoPeImage.ts rename to src/native/metadata/PeImage.ts index dfb9f9c579..f694550c71 100644 --- a/src/go/GoPeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -1,38 +1,41 @@ -import { observeMzWindowsHeader, parseDosMzHeader } from "../domain/dosMz.js"; +import { + observeMzWindowsHeader, + parseDosMzHeader, +} from "../../domain/dosMz.js"; import { declaredFileRange, - GoBinaryFormatFailure, - GoBinaryReader, - type GoBinaryContainer, - type GoFileMapping, - type GoImageSection, - type GoZeroFillMapping, -} from "./GoBinaryContainer.js"; + BinaryFormatFailure, + BinaryReader, + type BinaryContainer, + type FileMapping, + type ImageSection, + type ZeroFillMapping, +} from "./BinaryContainer.js"; -const peHeaderOffset = (reader: GoBinaryReader): number => { +const peHeaderOffset = (reader: BinaryReader): number => { const windowsHeader = observeMzWindowsHeader(reader.bytes); if (windowsHeader.state !== "declared") { const dos = parseDosMzHeader(reader.bytes); - if (!dos.ok) throw new GoBinaryFormatFailure("malformed", dos.error); - throw new GoBinaryFormatFailure( + if (!dos.ok) throw new BinaryFormatFailure("malformed", dos.error); + throw new BinaryFormatFailure( "unsupported", "DOS MZ images are outside the supported ELF, PE and thin Mach-O formats", ); } const { offset: pe } = windowsHeader; if (pe < 64) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE signature overlaps the DOS header", ); reader.range(pe, 24, "PE signature and COFF header"); if (["NE", "LE", "LX"].includes(windowsHeader.signature)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "unsupported", `${windowsHeader.signature} images are outside the supported ELF, PE and thin Mach-O formats`, ); if (windowsHeader.signature !== "PE") - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE signature is invalid or overlaps the DOS header", ); @@ -40,41 +43,41 @@ const peHeaderOffset = (reader: GoBinaryReader): number => { }; const optionalHeaderMagic = ( - reader: GoBinaryReader, + reader: BinaryReader, offset: number, size: number, ): number => { reader.range(offset, size, "PE optional header"); if (size < 2) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE optional header is missing its complete magic field", ); return reader.u16(offset); }; -/** Read PE32/PE32+ native image mappings and the linker data section. */ -export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { - const reader = new GoBinaryReader(bytes, true); +/** Read PE32/PE32+ native image mappings and section facts. */ +export const readPeImage = (bytes: Buffer): BinaryContainer => { + const reader = new BinaryReader(bytes, true); const pe = peHeaderOffset(reader); const optional = pe + 24; const optionalSize = reader.u16(pe + 20); const magic = optionalHeaderMagic(reader, optional, optionalSize); const bits = magic === 0x10b ? 32 : magic === 0x20b ? 64 : null; if (bits === null) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "unsupported", "Only PE32 and PE32+ image optional headers are supported", ); if (optionalSize < (bits === 64 ? 112 : 96)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE optional header is truncated", ); const directoriesOffset = bits === 64 ? 112 : 96; const directories = reader.u32(optional + directoriesOffset - 4); if (directories * 8 > optionalSize - directoriesOffset) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE data directories extend beyond the optional header", ); @@ -86,11 +89,10 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { 40, "PE sections", ); - const mappings: GoFileMapping[] = []; - const executableMappings: GoFileMapping[] = []; - const zeroFills: GoZeroFillMapping[] = []; - const sections: GoImageSection[] = []; - let search: GoFileMapping | null = null; + const mappings: FileMapping[] = []; + const executableMappings: FileMapping[] = []; + const zeroFills: ZeroFillMapping[] = []; + const sections: ImageSection[] = []; for (let index = 0; index < count; index++) { const entry = start + index * 40; const sectionName = peSectionName( @@ -103,19 +105,21 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { const characteristics = reader.u32(entry + 36); // SizeOfRawData includes file-alignment padding. VirtualSize is the content. const contentSize = virtualSize === 0 ? size : Math.min(virtualSize, size); - if (sectionName.length > 0) - sections.push( - peImageSection({ - reader, - name: sectionName, - size: contentSize, - offset, - characteristics, - }), - ); + sections.push( + peImageSection({ + reader, + name: sectionName, + size: contentSize, + offset, + characteristics, + virtualAddress, + virtualSize, + rawSize: size, + }), + ); const address = imageBase + BigInt(virtualAddress); if (address + BigInt(Math.max(size, virtualSize)) > 1n << BigInt(bits)) - throw new GoBinaryFormatFailure( + throw new BinaryFormatFailure( "malformed", "PE section virtual address overflows its address width", ); @@ -135,15 +139,6 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { address: address + BigInt(size), size: BigInt(virtualSize - size), }); - // Match debug/buildinfo's first initialized, readable, writable data section. - if ( - search === null && - virtualAddress !== 0 && - size !== 0 && - (characteristics & ~0x00600000) >>> 0 === 0xc0000040 - ) { - search = { ...mapping, size: contentSize }; - } } const machine = reader.u16(pe + 4); const names = new Map([ @@ -164,7 +159,6 @@ export const readGoPeImage = (bytes: Buffer): GoBinaryContainer => { executable_mappings: executableMappings, zero_fills: zeroFills, sections, - search: search === null ? [] : [search], }; }; @@ -179,13 +173,19 @@ const peImageSection = ({ size, offset, characteristics, + virtualAddress, + virtualSize, + rawSize, }: { - readonly reader: GoBinaryReader; + readonly reader: BinaryReader; readonly name: string; readonly size: number; readonly offset: number; readonly characteristics: number; -}): GoImageSection => { + readonly virtualAddress: number; + readonly virtualSize: number; + readonly rawSize: number; +}): ImageSection => { const range = declaredFileRange( reader.bytes.length, BigInt(offset), @@ -197,5 +197,15 @@ const peImageSection = ({ (characteristics & 0x80000000) === 0 && (characteristics & 0x20000000) === 0 && (characteristics & 0x40) !== 0; - return { name, ...range, read_only: readOnly, compressed: false }; + return { + format: "pe", + name, + virtual_address: virtualAddress, + virtual_size: virtualSize, + raw_size: rawSize, + characteristics, + ...range, + read_only: readOnly, + compressed: false, + }; }; diff --git a/src/native/metadata/readBinaryContainer.ts b/src/native/metadata/readBinaryContainer.ts new file mode 100644 index 0000000000..d8fa65f8a0 --- /dev/null +++ b/src/native/metadata/readBinaryContainer.ts @@ -0,0 +1,48 @@ +import { + BinaryFormatFailure, + type BinaryContainer, +} from "./BinaryContainer.js"; +import { + classifyFatContainerIdentity, + readFatContainerHeader, +} from "../../domain/apple/machoContainer.js"; +import { readElfImage } from "./ElfImage.js"; +import { readPeImage } from "./PeImage.js"; +import { readMachoImage } from "./MachoImage.js"; + +/** Identify an ELF, PE, or thin Mach-O image and its named file sections. */ +export const readBinaryContainer = (bytes: Buffer): BinaryContainer => { + if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) + return readElfImage(bytes); + if (bytes.subarray(0, 2).equals(Buffer.from("MZ"))) return readPeImage(bytes); + if (bytes.length >= 4) { + const magic = bytes.readUInt32BE(); + if ([0xcafebabe, 0xcafebabf, 0xbebafeca, 0xbfbafeca].includes(magic)) { + const fatHeader = readFatContainerHeader(bytes, bytes.length); + if (fatHeader.state === "malformed") + throw new BinaryFormatFailure("malformed", fatHeader.reason); + const identity = + fatHeader.state === "parsed" + ? classifyFatContainerIdentity(fatHeader, (offset) => { + if (offset >= bytes.length) return undefined; + if (offset + 4 > bytes.length) return null; + return bytes.readUInt32BE(offset); + }) + : undefined; + // An invalid FAT-shaped table is not proof of a Mach-O container: JVM + // constant-pool bytes can share both the magic and a known CPU value. + const confirmedFatMachO = identity?.state === "mach-o"; + if (confirmedFatMachO) + throw new BinaryFormatFailure( + "unsupported", + "Universal/fat Mach-O containers require selecting a thin slice", + ); + } + if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)) + return readMachoImage(bytes); + } + throw new BinaryFormatFailure( + "unsupported", + "Only ELF, PE, and thin Mach-O native images are supported", + ); +}; diff --git a/src/rust/RustBinaryImage.ts b/src/rust/RustBinaryImage.ts index ae077d21ca..01d0d9a66d 100644 --- a/src/rust/RustBinaryImage.ts +++ b/src/rust/RustBinaryImage.ts @@ -1,9 +1,9 @@ import { - GoBinaryFormatFailure, - GoBinaryResourceFailure, - type GoImageSection, -} from "../go/GoBinaryContainer.js"; -import { readGoBinaryContainer } from "../go/GoBinaryImage.js"; + BinaryFormatFailure, + BinaryResourceFailure, + type ImageSection, +} from "../native/metadata/BinaryContainer.js"; +import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import type { RustAuditableMetadata, RustBinary, @@ -48,13 +48,13 @@ export const readRustBinaryImage = (bytes: Buffer): RustBinaryImage => { const readContainer = (bytes: Buffer) => { try { - return readGoBinaryContainer(bytes); + return readBinaryContainer(bytes); } catch (cause) { - if (cause instanceof GoBinaryFormatFailure) + if (cause instanceof BinaryFormatFailure) throw new RustAuditableFormatFailure(cause.kind, cause.message, { cause, }); - if (cause instanceof GoBinaryResourceFailure) + if (cause instanceof BinaryResourceFailure) throw new RustAuditableResourceFailure( { resource: "memory", @@ -71,8 +71,8 @@ const readContainer = (bytes: Buffer) => { const dependencySection = ( format: RustBinaryImage["format"], - sections: readonly GoImageSection[], -): GoImageSection | null => { + sections: readonly ImageSection[], +): ImageSection | null => { const expected = format === "macho" ? "__DATA,.dep-v0" : ".dep-v0"; const matches = sections.filter((section) => section.name === expected); if (matches.length > 1) @@ -98,7 +98,7 @@ const dependencySection = ( const readDependency = ( bytes: Buffer, format: RustBinaryImage["format"], - section: GoImageSection, + section: ImageSection, ): RustAuditableMetadata => readAuditableSection( format === "macho" ? "__DATA,.dep-v0" : ".dep-v0", @@ -108,7 +108,7 @@ const readDependency = ( const rustcCommit = ( bytes: Buffer, - sections: readonly GoImageSection[], + sections: readonly ImageSection[], ): RustBinaryImage["rustc_commit"] => { const hashes: { hash: string; file_offset: number }[] = []; const seen = new Set(); @@ -120,7 +120,7 @@ const rustcCommit = ( }; const mergedReadOnlyRanges = ( - sections: readonly GoImageSection[], + sections: readonly ImageSection[], ): { readonly offset: number; readonly end: number }[] => { const ranges = sections .filter((section) => section.read_only && section.size > 0) diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index fbd8f69f56..d8d6dc94f5 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { deflateSync } from "node:zlib"; import { readFile, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -7,6 +8,8 @@ import { toolContract } from "../../src/contracts/toolContracts.js"; import { machoImageWithUnknownCommand } from "../../src/artifacts/apple/MachoImage.fixture.js"; import { createRustImage, + createRustImageWithGoSection, + RUSTC_HASH, rustDependencyPayload, } from "../fixtures/rust/image.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -229,3 +232,94 @@ cliTest( expect(await readFile(path)).toEqual(bytes); }, ); + +cliTest( + "keeps Rust evidence independent of Go section errors through public CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-rust-cli-go-section-"); + const { client, call } = await connectLocalToolsMcp(); + for (const [format, fault, code] of [ + ["elf", "compressed", "unsupported_target"], + ["elf", "zero-address", "invalid_request"], + ["macho", "zero-fill", "invalid_request"], + ] as const) { + const bytes = createRustImageWithGoSection(format, fault); + if (fault === "zero-address") { + const sectionHeader = 512; + expect(bytes.readUInt32LE(sectionHeader + 4)).toBe(1); + expect(bytes.readBigUInt64LE(sectionHeader + 8)).toBe(0n); + expect(bytes.readBigUInt64LE(sectionHeader + 16)).toBe(0n); + expect(bytes.readBigUInt64LE(sectionHeader + 24)).toBe(3072n); + expect(bytes.readBigUInt64LE(sectionHeader + 32)).toBe(16n); + const nameOffset = bytes.readUInt32LE(sectionHeader); + expect( + bytes.toString( + "ascii", + 768 + nameOffset, + 768 + nameOffset + ".go.buildinfo".length, + ), + ).toBe(".go.buildinfo"); + } + const path = join(root, `mixed.${format}`); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-rust-binary", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_rust_binary").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result).toMatchObject({ + artifact: { + path, + sha256: createHash("sha256").update(bytes).digest("hex"), + bytes: bytes.length, + }, + format, + auditable_metadata: { + file_offset: 1024, + root_package_index: 1, + packages: [ + { name: "itoa", version: "1.0.18" }, + { name: "sample", version: "0.1.0", root: true }, + ], + dependency_edges: [{ from_index: 1, to_index: 0 }], + }, + rustc_commit: { + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }, + }); + const response = await call("inspect_rust_binary", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + const go = await cli.run({ + arguments: ["inspect-go-binary", path, "--json"], + }); + expect(go.exitCode).toBe(1); + expect(go.json).toMatchObject({ + code, + details: { operation: "inspect_go_binary" }, + }); + if (fault === "zero-address") + expect(go.json).toMatchObject({ + details: { + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }); + const goResponse = await call("inspect_go_binary", { path }); + expect(parseMcpToolError(goResponse).error).toMatchObject({ + code, + details: { operation: "inspect_go_binary" }, + }); + if (fault === "zero-address") + expect(parseMcpToolError(goResponse).error).toMatchObject({ + details: { + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }); + } + await client.ping(); + }, + 30000, +); diff --git a/tests/boundary/go/goBinaryImage.test.ts b/tests/boundary/go/goBinaryImage.test.ts index 9bc91c1853..c45d26f361 100644 --- a/tests/boundary/go/goBinaryImage.test.ts +++ b/tests/boundary/go/goBinaryImage.test.ts @@ -5,11 +5,11 @@ import { fatImage, machoImage, } from "../../../src/artifacts/apple/MachoImage.fixture.js"; -import { - readGoBinaryImage, - GoBinaryResourceFailure, -} from "../../../src/go/GoBinaryImage.js"; -import { readGoMachoImage } from "../../../src/go/GoMachoImage.js"; +import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; +import { goBuildInfoSearch } from "../../../src/go/GoBuildInfoSearch.js"; +import { BinaryResourceFailure } from "../../../src/native/metadata/BinaryContainer.js"; +import { readMachoImage } from "../../../src/native/metadata/MachoImage.js"; +import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js"; import { createGoBinaryFixture, createGoSplitElfFixture, @@ -17,9 +17,43 @@ import { GO_MODULE_TEXT, } from "../../fixtures/go/image.js"; +it("preserves exact Mach-O protection masks for Go fallback selection", () => { + const { bytes } = createGoBinaryFixture({ + format: "macho", + sectionless: true, + }); + const firstSegment = 32; + const secondSegment = 104; + bytes.writeUInt32LE(2, 16); + bytes.writeUInt32LE(144, 20); + bytes.writeUInt32LE(11, firstSegment + 56); + bytes.writeUInt32LE(11, firstSegment + 60); + bytes.writeUInt32LE(0x19, secondSegment); + bytes.writeUInt32LE(72, secondSegment + 4); + bytes.write("__DATA2", secondSegment + 8, "ascii"); + bytes.writeBigUInt64LE(0x20000n, secondSegment + 24); + bytes.writeBigUInt64LE(BigInt(bytes.length), secondSegment + 32); + bytes.writeBigUInt64LE(0n, secondSegment + 40); + bytes.writeBigUInt64LE(BigInt(bytes.length), secondSegment + 48); + bytes.writeUInt32LE(3, secondSegment + 56); + bytes.writeUInt32LE(3, secondSegment + 60); + + const container = readBinaryContainer(bytes); + expect( + container.load_regions?.map((region) => region.protection_masks), + ).toEqual([ + { current: 11, maximum: 11 }, + { current: 3, maximum: 3 }, + ]); + expect(goBuildInfoSearch(bytes, container)).toMatchObject([ + { address: 0x20000n, offset: 0, size: bytes.length }, + ]); + expect(readGoBinaryImage(bytes).build_info?.go_version).toBe("go1.26.0"); +}); + it("bounds cumulative header searches across overlapping ELF file spans", () => { const bytes = createGoOverlappingElfFixture(); - expect(() => readGoBinaryImage(bytes)).toThrow(GoBinaryResourceFailure); + expect(() => readGoBinaryImage(bytes)).toThrow(BinaryResourceFailure); expect(() => readGoBinaryImage(bytes)).toThrow(/256 MiB.*search/); }); @@ -633,7 +667,7 @@ it("does not mark Mach-O section bytes outside their owning segment as read-only fixture.bytes.writeBigUInt64LE(0x10c00n, section + 32); fixture.bytes.writeBigUInt64LE(128n, section + 40); fixture.bytes.writeUInt32LE(3072, section + 48); - const image = readGoMachoImage(fixture.bytes); + const image = readMachoImage(fixture.bytes); expect(image.sections[0]).toMatchObject({ file_backed: false, read_only: false, diff --git a/tests/boundary/go/goBinaryMappings.test.ts b/tests/boundary/go/goBinaryMappings.test.ts index 96a850e48b..c0b4223225 100644 --- a/tests/boundary/go/goBinaryMappings.test.ts +++ b/tests/boundary/go/goBinaryMappings.test.ts @@ -1,5 +1,5 @@ import { expect, it } from "vitest"; -import { mappedFileOffset } from "../../../src/go/GoBinaryContainer.js"; +import { mappedFileOffset } from "../../../src/native/metadata/BinaryContainer.js"; const selected = { address: 100n, offset: 16, size: 64 }; diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index d4aa12283f..4f94a4cbdc 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -2,11 +2,14 @@ import { expect, it } from "vitest"; import { fat } from "../../../src/domain/binaryTarget.fixture.js"; import { createRustImage, + createRustImageWithGoSection, createRustHashOverflowImage, RUSTC_HASH, rustDependencyPayload, } from "../../fixtures/rust/image.js"; import { readRustBinaryImage } from "../../../src/rust/RustBinaryImage.js"; +import { readBinaryContainer } from "../../../src/native/metadata/readBinaryContainer.js"; +import { readGoBinaryImage } from "../../../src/go/GoBinaryImage.js"; import { RustAuditableFormatFailure } from "../../../src/rust/RustAuditableReader.js"; const packages = [ @@ -139,3 +142,48 @@ it("refuses read-only data with more distinct rustc paths than it retains", () = }), ); }); + +it.each([ + ["elf", "compressed"], + ["elf", "unmapped"], + ["elf", "zero-fill"], + ["elf", "duplicate"], + ["elf", "out-of-file"], + ["elf", "zero-address"], + ["macho", "unmapped"], + ["macho", "zero-fill"], + ["macho", "duplicate"], + ["macho", "out-of-file"], +] as const)( + "retains Rust evidence beside an unrelated %s %s Go section", + (format, fault) => { + const bytes = createRustImageWithGoSection(format, fault); + const control = Buffer.from(bytes); + const sectionOffset = format === "elf" ? 768 + 11 : 408; + const sectionName = Buffer.from( + (format === "elf" ? ".other\0" : "__other\0").padEnd( + format === "elf" ? 13 : 16, + "\0", + ), + "ascii", + ); + if (format === "elf") { + const nameOffset = Number(bytes.readBigUInt64LE(320 + 32)); + sectionName.copy(control, 768 + nameOffset); + } else { + sectionName.copy(control, sectionOffset); + } + expect(readBinaryContainer(bytes).load_regions).toEqual( + readBinaryContainer(control).load_regions, + ); + expect(() => readGoBinaryImage(bytes)).toThrow(/build-info|mapped/i); + expect(readRustBinaryImage(bytes)).toMatchObject({ + format, + auditable_metadata: { file_offset: 1024, packages }, + rustc_commit: { + evidence_kind: "inferred", + hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }], + }, + }); + }, +); diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts index 6bbac25abe..c80a40ad24 100644 --- a/tests/fixtures/rust/image.ts +++ b/tests/fixtures/rust/image.ts @@ -261,3 +261,75 @@ export const createRustHashOverflowImage = (): Buffer => { } return bytes; }; + +/** Valid Rust evidence beside an independently invalid Go metadata section. */ +export const createRustImageWithGoSection = ( + format: "elf" | "macho", + fault: + | "compressed" + | "unmapped" + | "zero-fill" + | "duplicate" + | "out-of-file" + | "zero-address", +): Buffer => { + const bytes = createRustImage({ format }); + const count = fault === "duplicate" ? 2 : 1; + if (format === "elf") { + const nameOffset = Number(bytes.readBigUInt64LE(320 + 32)); + const name = Buffer.from(".go.buildinfo\0"); + name.copy(bytes, 768 + nameOffset); + bytes.writeBigUInt64LE(BigInt(nameOffset + name.length), 320 + 32); + bytes.writeUInt16LE(4 + count, 60); + for (let index = 0; index < count; index++) { + const header = 512 + index * 64; + bytes.writeUInt32LE(nameOffset, header); + bytes.writeUInt32LE(fault === "zero-fill" ? 8 : 1, header + 4); + bytes.writeBigUInt64LE(fault === "compressed" ? 0x800n : 0n, header + 8); + bytes.writeBigUInt64LE( + fault === "zero-address" + ? 0n + : fault === "unmapped" + ? 0x600000n + : 0x400c00n, + header + 16, + ); + bytes.writeBigUInt64LE( + fault === "out-of-file" ? 4088n : 3072n, + header + 24, + ); + bytes.writeBigUInt64LE(fault === "zero-address" ? 16n : 32n, header + 32); + if (fault === "zero-address") + Buffer.from("additional data\0", "ascii").copy(bytes, 3072); + } + } else { + if (fault === "compressed") throw new Error("Not a Mach-O section flag"); + if (fault === "zero-address") throw new Error("Not an ELF section address"); + bytes.writeUInt32LE(2 + count, 16); + bytes.writeUInt32LE((2 + count) * 152, 20); + for (let index = 0; index < count; index++) { + const command = 336 + index * 152; + const little: RustImageWrite["little"] = (offset, value, width) => { + if (width === 16) bytes.writeUInt16LE(value, offset); + else if (width === 32) bytes.writeUInt32LE(value, offset); + else bytes.writeBigUInt64LE(BigInt(value), offset); + }; + writeMachoSegment({ + bytes, + little, + command, + segment: "__EXTRA", + section: "__go_buildinfo", + body: Buffer.alloc(32), + fileOffset: 3072, + protection: fault === "unmapped" ? 0 : 3, + flags: fault === "zero-fill" ? 1 : 0, + }); + if (fault === "out-of-file") bytes.writeUInt32LE(4088, command + 72 + 48); + bytes.writeBigUInt64LE(0x200000000n, command + 24); + bytes.writeBigUInt64LE(0x200000000n, command + 72 + 32); + if (fault === "unmapped") bytes.writeBigUInt64LE(0n, command + 32); + } + } + return bytes; +};