diff --git a/src/domain/elfDeclaredRanges.ts b/src/domain/elfDeclaredRanges.ts index d1812419bd..20a5859c7a 100644 --- a/src/domain/elfDeclaredRanges.ts +++ b/src/domain/elfDeclaredRanges.ts @@ -104,6 +104,22 @@ const extendedCounts = ( return ok(counts); }; +/** A name index or extended count cannot refer to an absent section table. */ +export const validateElfSectionTablePresence = (tables: { + readonly programs: { readonly count: number }; + readonly sections: { + readonly offset: bigint; + readonly count: number; + readonly namesIndex: number; + }; +}): Result => + tables.sections.offset === 0n && + (tables.sections.count !== 0 || + tables.sections.namesIndex !== 0 || + tables.programs.count === 0xffff) + ? err("ELF section table is missing") + : ok(null); + /** Canonical table declarations, including extended counts in inactive section zero. */ export function* readElfTableDeclarations( bytes: Buffer, @@ -122,11 +138,8 @@ export function* readElfTableDeclarations( let programCount = BigInt(programs.count); let sectionCount = BigInt(sections.count); let namesIndex = sections.namesIndex; - if ( - sectionOffset === 0n && - (sectionCount !== 0n || namesIndex !== 0 || programCount === 0xffffn) - ) - return err("ELF section table is missing"); + const presence = validateElfSectionTablePresence(raw.value); + if (!presence.ok) return presence; if ( sectionOffset !== 0n && (sectionCount === 0n || namesIndex === 0xffff || programCount === 0xffffn) diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 5ce0c87a10..844edfbb36 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -695,3 +695,25 @@ it("keeps compressed-note refusal ahead of alignment precision admission", () => "Compressed ELF note sections are unsupported", ); }); + +it.each([ + { bits: 32, little: true }, + { bits: 32, little: false }, + { bits: 64, little: true }, + { bits: 64, little: false }, +] as const)( + "rejects dangling section names with and without notes (ELF$bits $little)", + ({ bits, little }) => { + for (const ownership of ["program", "none"] as const) { + const bytes = elfNoteFixture({ bits, little, ownership }); + const before = parse(bytes); + const nameOffset = bits === 64 ? 62 : 50; + if (little) bytes.writeUInt16LE(5, nameOffset); + else bytes.writeUInt16BE(5, nameOffset); + expect(() => parse(bytes)).toThrow("ELF section table is missing"); + if (little) bytes.writeUInt16LE(0, nameOffset); + else bytes.writeUInt16BE(0, nameOffset); + expect(parse(bytes)).toEqual(before); + } + }, +); diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index f06ba6cca8..4112f9726d 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -4,6 +4,7 @@ import { } from "../../domain/elfStringTable.js"; import { readElfSectionHeader } from "../../domain/elfSectionHeader.js"; import { readElfProgramHeader } from "../../domain/elfProgramHeader.js"; +import { validateElfSectionTablePresence } from "../../domain/elfDeclaredRanges.js"; import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js"; import { createHash } from "node:crypto"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -308,6 +309,8 @@ export const parseElfNotes = ( sections.offset > BigInt(Number.MAX_SAFE_INTEGER) ) return fail("ELF offset or size exceeds a safe integer."); + const presence = validateElfSectionTablePresence(raw.value); + if (!presence.ok) return fail(presence.error); const phoff = Number(programs.offset); const shoff = Number(sections.offset); const phentsize = programs.width; diff --git a/tests/acceptance/elfNotesCli.test.ts b/tests/acceptance/elfNotesCli.test.ts index 13b31943a3..faffdd13aa 100644 --- a/tests/acceptance/elfNotesCli.test.ts +++ b/tests/acceptance/elfNotesCli.test.ts @@ -160,3 +160,48 @@ cliTest( expect((await readFile(path)).equals(before)).toBe(true); }, ); + +cliTest( + "rejects dangling section-name references and preserves sectionless notes", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-notes-presence-cli-"); + const path = join(root, "program"); + for (const ownership of ["program", "none"] as const) { + const bytes = elfNoteFixture({ ownership }); + bytes.writeUInt16LE(5, 62); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + operation: "inspect_elf_notes", + issues: [ + expect.objectContaining({ + path: ["path"], + reason: "invalid_format", + message: "ELF section table is missing", + }), + ], + }, + }); + expect((await readFile(path)).equals(bytes)).toBe(true); + bytes.writeUInt16LE(0, 62); + await writeFile(path, bytes); + const accepted = await cli.run({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(accepted.exitCode, accepted.stderr).toBe(0); + const evidence = toolContract("inspect_elf_notes").outputSchema.parse( + accepted.json, + ); + expect(evidence.normalized_result.coverage.status).toBe("complete"); + expect(evidence.normalized_result.tables).toHaveLength( + ownership === "none" ? 0 : 1, + ); + } + }, +); diff --git a/tests/boundary/mcp/elfNotesMcp.test.ts b/tests/boundary/mcp/elfNotesMcp.test.ts index 63686bc91d..02325edde9 100644 --- a/tests/boundary/mcp/elfNotesMcp.test.ts +++ b/tests/boundary/mcp/elfNotesMcp.test.ts @@ -163,3 +163,51 @@ mcpTest( } }, ); + +mcpTest( + "rejects dangling section-name references and recovers in the same session", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-elf-notes-presence-mcp-"); + const path = join(root, "program"); + const bytes = elfNoteFixture({ ownership: "program" }); + bytes.writeUInt16LE(5, 62); + await writeFile(path, bytes); + const rejected = await client.callTool({ + name: "inspect_elf_notes", + arguments: { path }, + }); + expect(rejected.isError).toBe(true); + expect(parseMcpToolError(rejected).error).toMatchObject({ + code: "invalid_request", + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + path: ["path"], + reason: "invalid_format", + message: "ELF section table is missing", + }), + ], + }, + }); + expect((await readFile(path)).equals(bytes)).toBe(true); + bytes.writeUInt16LE(0, 62); + await writeFile(path, bytes); + const accepted = await client.callTool({ + name: "inspect_elf_notes", + arguments: { path }, + }); + expect(accepted.isError).not.toBe(true); + const evidence = toolContract("inspect_elf_notes").outputSchema.parse( + accepted.structuredContent, + ); + expect(evidence.normalized_result.entries[0]?.gnu?.build_id_hex).toBe( + "0123456789abcdef0123456789abcdef01234567", + ); + } finally { + await session.close(); + } + }, +);