From 651afa445571017be461d92f7a9cd263de6d4248 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:00:13 +0800 Subject: [PATCH] fix(elf): validate declared file extents before provider admission --- docs/installation.md | 13 + src/application/BinaryTargetResolver.ts | 69 ++++- src/domain/elfDeclaredRanges.ts | 271 ++++++++++++++++++ src/native/metadata/NativeSectionDirectory.ts | 69 +---- .../filesystem/elfDeclaredRanges.test.ts | 159 ++++++++++ .../filesystem/mipsSharedLoad.test.ts | 59 +++- 6 files changed, 573 insertions(+), 67 deletions(-) create mode 100644 src/domain/elfDeclaredRanges.ts create mode 100644 tests/boundary/filesystem/elfDeclaredRanges.test.ts diff --git a/docs/installation.md b/docs/installation.md index 17985a2203..d14e266d54 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -968,6 +968,19 @@ dossiers, instructions, recovered data types, measured load mappings, loaded memory bytes, and observed file offsets. Independent load-image attestation supports DOS MZ, explicitly selected COM, and explicit raw-binary imports; PE returns measurements with an unsupported attestation status. +ELF target opening checks the declared program and section header tables and +their file-backed ranges before selecting a provider. A declaration beyond EOF +is refused with its range and the actual file size, including a file cut one +byte short. This also refuses carved or deliberately damaged copies that retain +out-of-file declarations; they are not presented as complete smaller programs. +Files declaring no tables remain valid inputs, subject to the provider's lane. +Inactive headers, zero-length ranges and `SHT_NOBITS` memory-only sections do +not require stored payload bytes. Extended counts come from section zero. +The check streams header records in chunks of at most 64 KiB; it does not decode +section names, copy payloads or impose the metadata decoder's structural budget. +Passing it establishes physical range consistency, not complete function +discovery, loader validity or runtime behavior. Existing ISA/ABI checks remain +separate, including the bounded recordless MIPS lane. Target admission preserves recognized PE/NE/LE/LX signatures separately from reader layout support. A Windows header overlapping the first 64 DOS-header bytes remains an unsupported layout in this release; it is not reported as an diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 4c08e862d1..b907c3fdf8 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -31,6 +31,7 @@ import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { err, ok, type Result } from "../domain/result.js"; import { readMipsElfMetadata } from "./MipsElfMetadata.js"; +import { validateElfDeclaredRanges } from "../domain/elfDeclaredRanges.js"; import { resolveAppBundleExecutable, type ResolvedAppBundle, @@ -852,6 +853,12 @@ const readExecutableMetadata = async ( signatureEvidence, ); } + if (bytes.length >= 4 && bytes.toString("hex", 0, 4) === "7f454c46") + return readElfMetadata(handle, bytes, { + fileSize, + hostArchitecture, + signal, + }); const thin = await readThinMachoMetadata(handle, bytes, { fileSize, hostArchitecture, @@ -881,11 +888,25 @@ const readExecutableMetadata = async ( if (!framed.ok) return framed; } } - if ( - !parsed.ok || - parsed.value.architecture !== "mips" || - parsed.value.mips.elfClass !== 32 - ) + return parsed; +}; + +/** ELF structure and family declarations share one descriptor before provider selection. */ +const readElfMetadata = async ( + handle: FileHandle, + bytes: Buffer, + options: { + readonly fileSize: number; + readonly hostArchitecture: NodeJS.Architecture; + readonly signal?: AbortSignal | undefined; + }, +): Promise> => { + const { fileSize, hostArchitecture, signal } = options; + const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize); + if (!parsed.ok || parsed.value.format !== "elf") return parsed; + const ranges = await readElfDeclaredRanges(handle, bytes, fileSize, signal); + if (!ranges.ok) return ranges; + if (parsed.value.architecture !== "mips" || parsed.value.mips.elfClass !== 32) return parsed; const declarations = await readMipsElfMetadata( handle, @@ -909,6 +930,44 @@ const readExecutableMetadata = async ( }); }; +/** Borrow the admitted descriptor; every request is at most one 64 KiB table chunk. */ +const readElfDeclaredRanges = async ( + handle: FileHandle, + prefix: Buffer, + fileSize: number, + signal?: AbortSignal, +): Promise> => { + const ranges = validateElfDeclaredRanges(prefix, fileSize); + let next = ranges.next(); + let window: Buffer | undefined; + while (!next.done) { + throwIfTargetResolutionCancelled(signal); + const { offset, size } = next.value; + let bytes: Buffer; + if (offset + size <= prefix.length) + bytes = prefix.subarray(offset, offset + size); + else { + window ??= Buffer.allocUnsafe(64 * 1024); + bytes = window.subarray(0, size); + let length = 0; + while (length < size) { + const read = await handle.read( + bytes, + length, + size - length, + offset + length, + ); + throwIfTargetResolutionCancelled(signal); + if (read.bytesRead === 0) break; + length += read.bytesRead; + } + bytes = bytes.subarray(0, length); + } + next = ranges.next(bytes); + } + return next.value; +}; + type ExecutableMetadataFailure = | string | { diff --git a/src/domain/elfDeclaredRanges.ts b/src/domain/elfDeclaredRanges.ts new file mode 100644 index 0000000000..06bc4e63b1 --- /dev/null +++ b/src/domain/elfDeclaredRanges.ts @@ -0,0 +1,271 @@ +import { readElfFixedHeader, type ElfFixedHeader } from "./elfFixedHeader.js"; +import { err, ok, type Result } from "./result.js"; + +/** Positional source requests; the caller owns the descriptor and cancellation. */ +export interface ElfDeclarationRead { + readonly offset: number; + readonly size: number; +} + +interface ElfDeclaredTable { + readonly offset: bigint; + readonly count: number; + readonly width: number; +} + +export interface ElfTableDeclarations { + readonly header: ElfFixedHeader; + readonly programs: ElfDeclaredTable; + readonly sections: ElfDeclaredTable & { readonly namesIndex: number }; +} + +const reader = (bytes: Buffer, header: ElfFixedHeader) => { + const little = header.byteOrder === "little"; + const u16 = (at: number) => + little ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at); + const u32 = (at: number) => + little ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at); + const word = (at: number) => + header.elfClass === 32 + ? BigInt(u32(at)) + : little + ? bytes.readBigUInt64LE(at) + : bytes.readBigUInt64BE(at); + return { u16, u32, word }; +}; + +/** Zero-sized/inactive declarations own no file bytes. Arithmetic stays exact. */ +const rangeFailure = ( + label: string, + offset: bigint, + size: bigint, + fileSize: number, +): string | undefined => + size !== 0n && (offset > BigInt(fileSize) || size > BigInt(fileSize) - offset) + ? `truncated ELF ${label}: declared file range ${String(offset)}..${String(offset + size)} exceeds the ${String(fileSize)}-byte file` + : undefined; + +interface RawTableDeclaration { + readonly offset: bigint; + readonly count: bigint; + readonly width: number; +} + +const tableFailure = ( + kind: "program" | "section", + table: RawTableDeclaration, + header: ElfFixedHeader, + fileSize: number, +): string | undefined => { + if (table.count === 0n) return undefined; + const wide = header.elfClass === 64; + const minimumWidth = kind === "program" ? (wide ? 56 : 32) : wide ? 64 : 40; + const label = `${kind} header table`; + if (table.width < minimumWidth) return `invalid ELF ${label} entry width`; + if (table.offset < BigInt(wide ? 64 : 52)) + return `ELF ${label} overlaps the fixed header`; + return rangeFailure( + label, + table.offset, + table.count * BigInt(table.width), + fileSize, + ); +}; + +interface TableCounts { + programCount: bigint; + sectionCount: bigint; + namesIndex: number; +} + +const extendedCounts = ( + first: Buffer, + header: ElfFixedHeader, + initial: TableCounts, +): Result => { + const wide = header.elfClass === 64; + if (first.length !== (wide ? 64 : 40)) + return err("truncated ELF extended section-zero header"); + const zero = reader(first, header); + if (zero.u32(4) !== 0) + return err( + "ELF extended numbering requires an inactive SHT_NULL section zero", + ); + const counts = { ...initial }; + if (counts.sectionCount === 0n) { + counts.sectionCount = zero.word(wide ? 32 : 20); + if (counts.sectionCount < 0xff00n) + return err("invalid ELF extended section count"); + } + if (counts.namesIndex === 0xffff) { + counts.namesIndex = zero.u32(wide ? 40 : 24); + if (counts.namesIndex < 0xff00) + return err("invalid ELF extended section-name index"); + } + if (counts.programCount === 0xffffn) { + counts.programCount = BigInt(zero.u32(wide ? 44 : 28)); + if (counts.programCount < 0xffffn) + return err("invalid ELF extended program count"); + } + return ok(counts); +}; + +const TABLE_FIELDS = { + 32: { + programOffset: 28, + sectionOffset: 32, + programWidth: 42, + programCount: 44, + sectionWidth: 46, + sectionCount: 48, + namesIndex: 50, + sectionHeaderSize: 40, + }, + 64: { + programOffset: 32, + sectionOffset: 40, + programWidth: 54, + programCount: 56, + sectionWidth: 58, + sectionCount: 60, + namesIndex: 62, + sectionHeaderSize: 64, + }, +} as const; + +/** Canonical table declarations, including extended counts in inactive section zero. */ +export function* readElfTableDeclarations( + bytes: Buffer, + fileSize: number, +): Generator, Buffer> { + const fixed = readElfFixedHeader(bytes, fileSize); + if (!fixed.ok) return fixed; + const header = fixed.value; + const r = reader(bytes, header); + const fields = TABLE_FIELDS[header.elfClass]; + const programOffset = r.word(fields.programOffset); + const sectionOffset = r.word(fields.sectionOffset); + const programWidth = r.u16(fields.programWidth); + const sectionWidth = r.u16(fields.sectionWidth); + let programCount = BigInt(r.u16(fields.programCount)); + let sectionCount = BigInt(r.u16(fields.sectionCount)); + let namesIndex = r.u16(fields.namesIndex); + if ( + sectionOffset === 0n && + (sectionCount !== 0n || namesIndex !== 0 || programCount === 0xffffn) + ) + return err("ELF section table is missing"); + if ( + sectionOffset !== 0n && + (sectionCount === 0n || namesIndex === 0xffff || programCount === 0xffffn) + ) { + const failure = tableFailure( + "section", + { offset: sectionOffset, count: 1n, width: sectionWidth }, + header, + fileSize, + ); + if (failure !== undefined) return err(failure); + const first = yield { + offset: Number(sectionOffset), + size: fields.sectionHeaderSize, + }; + const counts = extendedCounts(first, header, { + programCount, + sectionCount, + namesIndex, + }); + if (!counts.ok) return counts; + ({ programCount, sectionCount, namesIndex } = counts.value); + } + for (const [kind, offset, count, width] of [ + ["program", programOffset, programCount, programWidth], + ["section", sectionOffset, sectionCount, sectionWidth], + ] as const) { + const failure = tableFailure( + kind, + { offset, count, width }, + header, + fileSize, + ); + if (failure !== undefined) return err(failure); + } + if (sectionCount !== 0n && BigInt(namesIndex) >= sectionCount) + return err( + "ELF section-name index lies outside the declared section table", + ); + // A validated table fits the host-sized file, so its record count is exact. + return ok({ + header, + programs: { + offset: programOffset, + count: Number(programCount), + width: programWidth, + }, + sections: { + offset: sectionOffset, + count: Number(sectionCount), + width: sectionWidth, + namesIndex, + }, + }); +} + +/** Examine only header records; referenced payloads are range-checked, never copied. */ +function* inspectTableRanges( + kind: "program" | "section", + table: ElfDeclaredTable, + header: ElfFixedHeader, + fileSize: number, +): Generator, Buffer> { + const wide = header.elfClass === 64; + const perRead = Math.max(1, Math.floor((64 * 1024) / table.width)); + for (let index = 0; index < table.count; index += perRead) { + const count = Math.min(perRead, table.count - index); + const size = count * table.width; + const bytes = yield { + offset: Number(table.offset) + index * table.width, + size, + }; + if (bytes.length !== size) return err(`truncated ELF ${kind} header table`); + const r = reader(bytes, header); + for (let item = 0; item < count; item++) { + const at = item * table.width; + const type = r.u32(at + (kind === "program" ? 0 : 4)); + if (type === 0 || (kind === "section" && type === 8)) continue; + const offset = r.word( + at + (kind === "program" ? (wide ? 8 : 4) : wide ? 24 : 16), + ); + const length = r.word( + at + (kind === "program" ? (wide ? 32 : 16) : wide ? 32 : 20), + ); + const failure = rangeFailure( + `${kind} ${String(index + item)}`, + offset, + length, + fileSize, + ); + if (failure !== undefined) return err(failure); + } + } + return ok(null); +} + +/** Physical extent admission, independent of section names, CPU lanes and decoder budgets. */ +export function* validateElfDeclaredRanges( + prefix: Buffer, + fileSize: number, +): Generator, Buffer> { + const declarations = yield* readElfTableDeclarations(prefix, fileSize); + if (!declarations.ok) return declarations; + const { header, programs, sections } = declarations.value; + for (const [kind, table] of [ + ["program", programs], + ["section", sections], + ] as const) { + if (table.count === 0) continue; + const inspected = yield* inspectTableRanges(kind, table, header, fileSize); + if (!inspected.ok) return inspected; + } + return ok(null); +} diff --git a/src/native/metadata/NativeSectionDirectory.ts b/src/native/metadata/NativeSectionDirectory.ts index 323b79f444..dc88f86538 100644 --- a/src/native/metadata/NativeSectionDirectory.ts +++ b/src/native/metadata/NativeSectionDirectory.ts @@ -1,4 +1,4 @@ -import { readElfFixedHeader } from "../../domain/elfFixedHeader.js"; +import { readElfTableDeclarations } from "../../domain/elfDeclaredRanges.js"; import { BinaryFormatFailure, BinaryReader, @@ -78,63 +78,26 @@ export function* readElfSectionDirectory( const prefix = yield request(fileSize, 0n, 16n); const headerSize = prefix[4] === 1 ? 52 : 64; const bytes = yield request(fileSize, 0n, BigInt(headerSize)); - const parsed = readElfFixedHeader(bytes, fileSize); + const parsed = yield* readElfTableDeclarations(bytes, fileSize); if (!parsed.ok) return reject(parsed.error); - const { elfClass: bits, byteOrder, machine } = parsed.value; + const { header, programs, sections: sectionTable } = parsed.value; + const { elfClass: bits, byteOrder, machine } = header; const little = byteOrder === "little"; const wide = bits === 64; const r = new BinaryReader(bytes, little); - const program_offset = r.word(wide ? 32 : 28, bits); - const sectionOffset = r.word(wide ? 40 : 32, bits); - const program_width = r.u16(wide ? 54 : 42); - const sectionWidth = r.u16(wide ? 58 : 46); - let program_count = r.u16(wide ? 56 : 44); - let count = r.u16(wide ? 60 : 48); - let namesIndex = r.u16(wide ? 62 : 50); - if ( - (program_count !== 0 && program_offset < BigInt(headerSize)) || - (sectionOffset !== 0n && sectionOffset < BigInt(headerSize)) - ) - reject("ELF header table overlaps the native header"); - if ( - sectionOffset === 0n && - (count !== 0 || namesIndex !== 0 || program_count === 0xffff) - ) - reject("ELF section table is missing"); - if ( - sectionOffset !== 0n && - (count === 0 || namesIndex === 0xffff || program_count === 0xffff) - ) { - if (sectionWidth < (wide ? 64 : 40)) - reject("ELF extended section header width is invalid"); - const first = new BinaryReader( - yield request(fileSize, sectionOffset, BigInt(sectionWidth)), - little, - ); - if (first.u32(4) !== 0) - reject( - "ELF initial section for extended numbering must have SHT_NULL type", - ); - if (count === 0) { - const declared = first.word(wide ? 32 : 20, bits); - if (declared < 0xff00n) reject("ELF extended section count is invalid"); - count = exact(declared); - } - if (namesIndex === 0xffff) { - namesIndex = first.u32(wide ? 40 : 24); - if (namesIndex < 0xff00) - reject("ELF extended section name index is below its reserved range"); - } - if (program_count === 0xffff) { - program_count = first.u32(wide ? 44 : 28); - if (program_count < 0xffff) - reject("ELF extended program count is below its reserved range"); - } - } + const { + offset: program_offset, + width: program_width, + count: program_count, + } = programs; + const { + offset: sectionOffset, + width: sectionWidth, + count, + namesIndex, + } = sectionTable; let has_dynamic_segment = false; if (program_count !== 0) { - if (program_width < (wide ? 56 : 32)) - reject("ELF program header width is invalid"); const programs = new BinaryReader( yield request( fileSize, @@ -153,8 +116,6 @@ export function* readElfSectionDirectory( const sections: NativeSection[] = []; let section_names_limitation: string | undefined; if (count !== 0) { - if (sectionWidth < (wide ? 64 : 40) || namesIndex >= count) - reject("ELF section width or name-table index is invalid"); const table = new BinaryReader( yield request( fileSize, diff --git a/tests/boundary/filesystem/elfDeclaredRanges.test.ts b/tests/boundary/filesystem/elfDeclaredRanges.test.ts new file mode 100644 index 0000000000..81e0041c40 --- /dev/null +++ b/tests/boundary/filesystem/elfDeclaredRanges.test.ts @@ -0,0 +1,159 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const image = (bits: 32 | 64, little: boolean) => { + const bytes = Buffer.alloc(368); + const header = elf(bits === 32 ? 1 : 2, little ? 1 : 2, bits === 32 ? 3 : 62); + header.copy(bytes); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const u16 = (offset: number, value: number) => + view.setUint16(offset, value, little); + const u32 = (offset: number, value: number) => + view.setUint32(offset, value, little); + const word = (offset: number, value: bigint) => + bits === 32 + ? u32(offset, Number(value)) + : view.setBigUint64(offset, value, little); + const wide = bits === 64; + const program = wide ? 64 : 52; + const programWidth = wide ? 56 : 32; + const sectionWidth = wide ? 64 : 40; + u16(16, 2); + word(wide ? 32 : 28, BigInt(program)); + word(wide ? 40 : 32, 128n); + u16(wide ? 54 : 42, programWidth); + u16(wide ? 56 : 44, 1); + u16(wide ? 58 : 46, sectionWidth); + u16(wide ? 60 : 48, 2); + u32(program, 1); // PT_LOAD + word(program + (wide ? 8 : 4), 320n); + word(program + (wide ? 32 : 16), 16n); + word(program + (wide ? 40 : 20), 16n); + const section = 128 + sectionWidth; + u32(section + 4, 1); // SHT_PROGBITS + word(section + (wide ? 24 : 16), 352n); + word(section + (wide ? 32 : 20), 16n); + return { + bytes, + word, + u16, + u32, + program, + section, + programWidth, + sectionWidth, + }; +}; + +const admit = async (bytes: Buffer) => { + const directory = await createTestTempDirectory("rea-elf-declared-ranges-"); + const path = join(directory, "image.elf"); + await writeFile(path, bytes); + const result = await parseBinaryTarget(path); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect( + createHash("sha256") + .update(await readFile(path)) + .digest("hex"), + ).toBe(digest); + if (result.ok) expect(result.value.sha256).toBe(digest); + return result; +}; + +for (const bits of [32, 64] as const) { + for (const little of [true, false]) { + it(`refuses declared table and payload truncation before provider admission (ELF${bits} ${little ? "LE" : "BE"})`, async () => { + const fixture = image(bits, little); + expect((await admit(fixture.bytes)).ok).toBe(true); + for (const [length, declaration] of [ + [fixture.program, "program header table"], + [fixture.program + fixture.programWidth - 1, "program header table"], + [128 + fixture.sectionWidth * 2 - 1, "section header table"], + [335, "program 0"], + [367, "section 1"], + ] as const) { + const result = await admit(fixture.bytes.subarray(0, length)); + expect(result).toMatchObject({ + ok: false, + error: { + constraint: "file_format_rejected", + reason: expect.stringContaining(`truncated ELF ${declaration}`), + }, + }); + if (result.ok) throw new Error("Expected a declared range refusal"); + expect(result.error.reason).toContain(`${length}-byte file`); + } + }); + + it(`preserves absent tables and memory-only declarations (ELF${bits} ${little ? "LE" : "BE"})`, async () => { + expect( + ( + await admit( + elf(bits === 32 ? 1 : 2, little ? 1 : 2, bits === 32 ? 3 : 62), + ) + ).ok, + ).toBe(true); + const fixture = image(bits, little); + const wide = bits === 64; + const outside = wide ? 0xffffffffffffffffn : 0xffffffffn; + fixture.u32(fixture.section + 4, 8); // SHT_NOBITS owns memory, not file bytes. + fixture.word(fixture.section + (wide ? 24 : 16), outside); + fixture.word(fixture.section + (wide ? 32 : 20), outside); + fixture.word(fixture.program + (wide ? 8 : 4), outside); + fixture.word(fixture.program + (wide ? 32 : 16), 0n); + fixture.word(128 + (wide ? 24 : 16), outside); // Inactive section-zero fields. + fixture.word(128 + (wide ? 32 : 20), outside); + expect((await admit(fixture.bytes)).ok).toBe(true); + }); + + it(`keeps declared offsets exact when their sum exceeds host precision (ELF${bits} ${little ? "LE" : "BE"})`, async () => { + const fixture = image(bits, little); + const maximum = bits === 64 ? 0xffffffffffffffffn : 0xffffffffn; + fixture.word(fixture.program + (bits === 64 ? 8 : 4), maximum); + const result = await admit(fixture.bytes); + expect(result).toMatchObject({ ok: false }); + if (result.ok) throw new Error("Expected an out-of-file range"); + expect(result.error.reason).toContain(`${maximum}..${maximum + 16n}`); + }); + } +} + +it.each(["sections", "programs", "section names"] as const)( + "resolves valid extended %s numbering from bounded section zero", + async (kind) => { + const count = + kind === "programs" ? 0xffff : kind === "section names" ? 0xff01 : 0xff00; + const width = kind === "programs" ? 56 : 64; + const sectionAt = kind === "programs" ? 64 + count * width : 64; + const bytes = Buffer.alloc( + kind === "programs" ? sectionAt + 64 : sectionAt + count * width, + ); + elf(2, 1, 62).copy(bytes); + bytes.writeBigUInt64LE(BigInt(sectionAt), 40); + bytes.writeUInt16LE(64, 58); + if (kind !== "programs") { + bytes.writeBigUInt64LE(BigInt(count), sectionAt + 32); + if (kind === "section names") { + bytes.writeUInt16LE(0xffff, 62); + bytes.writeUInt32LE(0xff00, sectionAt + 40); + bytes.writeUInt32LE(3, sectionAt + 0xff00 * width + 4); + } + } else { + bytes.writeBigUInt64LE(64n, 32); + bytes.writeUInt16LE(56, 54); + bytes.writeUInt16LE(0xffff, 56); + bytes.writeUInt16LE(1, 60); + bytes.writeUInt32LE(count, sectionAt + 44); + } + expect((await admit(bytes)).ok).toBe(true); + const truncated = await admit(bytes.subarray(0, bytes.length - 1)); + expect(truncated).toMatchObject({ ok: false }); + if (truncated.ok) throw new Error("Expected an extended table refusal"); + expect(truncated.error.reason).toContain("truncated ELF"); + }, +); diff --git a/tests/boundary/filesystem/mipsSharedLoad.test.ts b/tests/boundary/filesystem/mipsSharedLoad.test.ts index 41c5434f98..9f8665e5b7 100644 --- a/tests/boundary/filesystem/mipsSharedLoad.test.ts +++ b/tests/boundary/filesystem/mipsSharedLoad.test.ts @@ -52,10 +52,31 @@ const fixture = (little: boolean, count = 2): Buffer => { } return bytes; }; -const resolve = async (bytes: Buffer) => { +const writeFixture = async (bytes: Buffer) => { const directory = await createTestTempDirectory("rea-mips-shared-test-"); const path = join(directory, "shared.elf"); await writeFile(path, bytes); + return path; +}; +const inspectSource = async (path: string) => { + const handle = await open(path, "r"); + try { + return await readMipsElfMetadata( + handle, + { + elfClass: 32, + byteOrder: "little", + type: 3, + flags: 0x74001005, + }, + () => undefined, + ); + } finally { + await handle.close(); + } +}; +const resolve = async (bytes: Buffer) => { + const path = await writeFixture(bytes); const result = await parseBinaryTarget(path); assert.ok(result.ok); assert.equal(result.value.kind, "executable"); @@ -201,16 +222,23 @@ it("does not treat uncaptured ABI declaration headers as an inspected absence", bytes.writeUInt32LE(bytes.length + 1, 32); bytes.writeUInt16LE(40, 46); bytes.writeUInt16LE(1, 48); - const target = await resolve(bytes); - assert.equal(target.mips.abiFlags, null); - assert.equal(target.mips.sharedLoad?.status, "unavailable"); + const path = await writeFixture(bytes); + const admitted = await parseBinaryTarget(path); + assert.ok(!admitted.ok); + assert.match(admitted.error.reason, /truncated ELF section header table/u); + // The specialized inspector still retains unknown source facts if used on + // damaged evidence; normal target admission refuses its impossible extent. + const declarations = await inspectSource(path); + assert.ok(declarations.ok); + assert.equal(declarations.value.abiFlags, null); + assert.equal(declarations.value.sharedLoad?.status, "unavailable"); assert.match( - ghidraMipsUnsupportedReason(target) ?? "", - /unavailable headers remain unknown/u, + declarations.value.limitations.join(" "), + /extends beyond the captured file/u, ); }); -const malformed: [string, (bytes: Buffer) => void, RegExp][] = [ +const malformed: [string, (bytes: Buffer) => void, RegExp, boolean?][] = [ [ "overlapping load ranges", (b) => { @@ -233,11 +261,13 @@ const malformed: [string, (bytes: Buffer) => void, RegExp][] = [ "declared dynamic tail outside file", (b) => b.writeUInt32LE(b.length, 100), /beyond the file/u, + true, ], [ "unavailable load bytes", (b) => b.writeUInt32LE(b.length + 1, 68), /PT_LOAD range/u, + true, ], ["missing terminator", (b) => b.writeUInt32LE(1, 280), /DT_NULL/u], [ @@ -268,9 +298,22 @@ const malformed: [string, (bytes: Buffer) => void, RegExp][] = [ ]; it.each(malformed)( "preserves useful partial header facts for %s", - async (_name, mutate, reason) => { + async (_name, mutate, reason, rejectsExtent) => { const bytes = fixture(true); mutate(bytes); + if (rejectsExtent) { + const path = await writeFixture(bytes); + const admitted = await parseBinaryTarget(path); + assert.ok(!admitted.ok); + assert.match(admitted.error.reason, /truncated ELF program \d/u); + const declarations = await inspectSource(path); + assert.ok(declarations.ok); + assert.equal(declarations.value.sharedLoad?.status, "unavailable"); + if (declarations.value.sharedLoad?.status !== "unavailable") + throw new Error("Expected retained unknown relocation coverage"); + assert.match(declarations.value.sharedLoad.reason, reason); + return; + } const target = await resolve(bytes); assert.equal(target.mips.flags, 0x74001005); assert.equal(target.mips.abiFlags, null);