diff --git a/docs/elf-notes.md b/docs/elf-notes.md index 4d3a78deae..dff138c6c9 100644 --- a/docs/elf-notes.md +++ b/docs/elf-notes.md @@ -24,8 +24,8 @@ requires an absolute filesystem path and preserves that exact selection: The inline Evidence result contains the artifact path, SHA-256, and byte length. `tables` lists each declared note program header or section, including the -section name when a string table is present. Zlib-compressed section-name -tables are decompressed within the selected file-size budget. An unsupported +section name when a string table is present. Zlib- and Zstandard-compressed +section-name tables are decompressed within the selected file-size budget. An unsupported compression type leaves section names unknown and adds a limitation; note bytes remain independently examined. Each `entries[]` item reports owner, type, descriptor SHA-256, and a hex preview for descriptors of 64 bytes or less. @@ -63,6 +63,8 @@ numbering (`PN_XNUM`, including a section count stored when `e_shnum` is 0) is unsupported and returns an unsupported-target outcome rather than invalid-input recovery. +Zstandard section-name tables also use a 128 MiB decoder window limit. + `SHF_COMPRESSED` note sections return `unsupported_target` before their payload is interpreted or decompressed. Their compression header is storage metadata, not a note header. The current result requires physical file offsets for each diff --git a/docs/rust-binaries.md b/docs/rust-binaries.md index b235ce0137..77bcd8c982 100644 --- a/docs/rust-binaries.md +++ b/docs/rust-binaries.md @@ -83,7 +83,8 @@ empty dependency set. Retained views describe the captured artifact digest; they do not assert that the current file or running application is unchanged. ELF section names are resolved through the section-header string table. -Zlib-compressed name tables are decoded within the native structural budget. +Zlib- and Zstandard-compressed name tables are decoded within the native structural +budget. Zstandard decoding also uses a 128 MiB window limit. If a name table uses unsupported compression, the result reports that limitation and leaves `.dep-v0` presence unknown rather than reporting the section absent. diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 05b67818ed..f87312b1d6 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -4,6 +4,7 @@ import { expect, it } from "vitest"; import { inspectElfNotesInputSchema } from "../../domain/native/elfNotes.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; +import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js"; import { compressedElfNoteFixture, compressedElfNoteSectionFixture, @@ -149,12 +150,20 @@ it("preserves unknown ELF64 section flag bits without treating them as file size }); it.each([ - [32, true], - [64, false], + [32, true, 1], + [32, false, 1], + [64, true, 1], + [64, false, 1], + [32, true, 2], + [32, false, 2], + [64, true, 2], + [64, false, 2], ] as const)( - "decompresses a zlib section-name table for ELF%i %s-endian notes", - (bits, little) => { - const report = parse(compressedElfNoteFixture(bits, little)); + "decompresses an ELF%i little-endian=%s section-name table using algorithm %i", + (bits, little, compressionType) => { + const report = parse( + compressedElfNoteFixture(bits, little, compressionType), + ); expect(report.tables).toEqual([ expect.objectContaining({ kind: "section", name: ".note.gnu.build-id" }), ]); @@ -164,22 +173,72 @@ it.each([ }, ); -it("admits a decoded section name larger than its compressed source", () => { - const name = "n".repeat(512); - const bytes = compressedElfNoteFixture(64, true, 1, name); - expect(bytes.length).toBeLessThan(name.length); - const report = parse(bytes); - expect(report.tables[0]?.name).toBe(name); +it.each([1, 2])( + "admits a decoded section name larger than algorithm %i storage", + (compressionType) => { + const name = "n".repeat(512); + const bytes = compressedElfNoteFixture(64, true, compressionType, name); + expect(bytes.length).toBeLessThan(name.length); + const report = parse(bytes); + expect(report.tables[0]?.name).toBe(name); + }, +); + +it.each([1, 2])( + "rejects malformed algorithm %i section-name storage", + (compressionType) => { + for (const damage of ["corrupt", "truncated", "too-large", "too-small"]) { + const bytes = compressedElfNoteFixture(64, true, compressionType); + const sectionOffset = Number(bytes.readBigUInt64LE(40)); + const nameHeader = sectionOffset + bytes.readUInt16LE(62) * 64; + const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24)); + if (damage === "corrupt") bytes[namesOffset + 24] = 0; + else if (damage === "truncated") + bytes.writeBigUInt64LE( + bytes.readBigUInt64LE(nameHeader + 32) - 1n, + nameHeader + 32, + ); + else + bytes.writeBigUInt64LE( + bytes.readBigUInt64LE(namesOffset + 8) + + (damage === "too-large" ? 1n : -1n), + namesOffset + 8, + ); + expect(() => parse(bytes), damage).toThrow( + /compressed section-name table/u, + ); + } + }, +); + +it("refuses a Zstandard frame requiring more than the decoder window allowance", () => { + const header = Buffer.alloc(24); + header.writeUInt32LE(2, 0); + header.writeBigUInt64LE(1n, 16); + // An empty last raw block with Window_Descriptor 0x90: a 2^28-byte window. + const frame = Buffer.from("28b52ffd0090010000", "hex"); + expect( + decodeElfSectionNameTable( + Buffer.concat([header, frame]), + 0x800n, + 64, + true, + 0, + ), + ).toMatchObject({ + state: "malformed", + message: expect.stringMatching(/memory/iu), + }); }); it("keeps note facts but marks section names unavailable for unknown compression", () => { - const report = parse(compressedElfNoteFixture(64, true, 2)); + const report = parse(compressedElfNoteFixture(64, true, 3)); expect(report.tables[0]?.name).toBeNull(); expect(report.entries[0]?.gnu?.build_id_hex).toBe( "0123456789abcdef0123456789abcdef01234567", ); expect(report.limitations).toContain( - "ELF section names are unavailable because compression type 2 is unsupported.", + "ELF section names are unavailable because compression type 3 is unsupported.", ); }); @@ -197,38 +256,41 @@ it("tags valid extended section numbering as unsupported", () => { }); }); -it("admits compressed section names when max_file_bytes includes expansion", () => { - const bytes = compressedElfNoteFixture(); - const sectionOffset = Number(bytes.readBigUInt64LE(40)); - const nameHeader = sectionOffset + bytes.readUInt16LE(62) * 64; - const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24)); - const decodedNamesBytes = Number(bytes.readBigUInt64LE(namesOffset + 8)); - const minimumMaxFileBytes = bytes.length + decodedNamesBytes; - const input = inspectElfNotesInputSchema.parse({ - path: defaults.path, - max_file_bytes: bytes.length + 1, - }); - let failure: unknown; - try { - parse(bytes, input); - } catch (cause) { - failure = cause; - } - expect(failure).toBeInstanceOf(ElfNoteSectionNameBudgetFailure); - expect(failure).toMatchObject({ - minimumMaxFileBytes: BigInt(minimumMaxFileBytes), - selectedMaxFileBytes: bytes.length + 1, - }); - - const admitted = parse( - bytes, - inspectElfNotesInputSchema.parse({ +it.each([1, 2])( + "admits algorithm %i section names when max_file_bytes includes expansion", + (compressionType) => { + const bytes = compressedElfNoteFixture(64, true, compressionType); + const sectionOffset = Number(bytes.readBigUInt64LE(40)); + const nameHeader = sectionOffset + bytes.readUInt16LE(62) * 64; + const namesOffset = Number(bytes.readBigUInt64LE(nameHeader + 24)); + const decodedNamesBytes = Number(bytes.readBigUInt64LE(namesOffset + 8)); + const minimumMaxFileBytes = bytes.length + decodedNamesBytes; + const input = inspectElfNotesInputSchema.parse({ path: defaults.path, - max_file_bytes: minimumMaxFileBytes, - }), - ); - expect(admitted.tables[0]?.name).toBe(".note.gnu.build-id"); -}); + max_file_bytes: bytes.length + 1, + }); + let failure: unknown; + try { + parse(bytes, input); + } catch (cause) { + failure = cause; + } + expect(failure).toBeInstanceOf(ElfNoteSectionNameBudgetFailure); + expect(failure).toMatchObject({ + minimumMaxFileBytes: BigInt(minimumMaxFileBytes), + selectedMaxFileBytes: bytes.length + 1, + }); + + const admitted = parse( + bytes, + inspectElfNotesInputSchema.parse({ + path: defaults.path, + max_file_bytes: minimumMaxFileBytes, + }), + ); + expect(admitted.tables[0]?.name).toBe(".note.gnu.build-id"); + }, +); it("reports independent PT_NOTE and SHT_NOTE declarations of the same bytes", () => { const report = parse(elfNoteFixture({ ownership: "both" })); diff --git a/src/native/elf/ElfNotes.fixture.ts b/src/native/elf/ElfNotes.fixture.ts index fc334da95b..6d31995d9b 100644 --- a/src/native/elf/ElfNotes.fixture.ts +++ b/src/native/elf/ElfNotes.fixture.ts @@ -1,4 +1,4 @@ -import { deflateSync } from "node:zlib"; +import { deflateSync, zstdCompressSync } from "node:zlib"; const concat = (...parts: Buffer[]): Buffer => Buffer.concat(parts); @@ -293,7 +293,7 @@ export const elfExtendedSectionCountFixture = (): Buffer => { return bytes; }; -/** ELF note fixture with a zlib-compressed section-name string table. */ +/** ELF note fixture with a compressed section-name string table. */ export const compressedElfNoteFixture = ( bits: 32 | 64 = 64, little = true, @@ -326,7 +326,8 @@ export const compressedElfNoteFixture = ( const namesOffset = word(namesHeader + (wide ? 24 : 16)); const namesSize = word(namesHeader + (wide ? 32 : 20)); const names = image.subarray(namesOffset, namesOffset + namesSize); - const compressed = deflateSync(names); + const compressed = + compressionType === 2 ? zstdCompressSync(names) : deflateSync(names); const compressionHeaderBytes = wide ? 24 : 12; const replacement = Buffer.alloc(compressionHeaderBytes); if (little) replacement.writeUInt32LE(compressionType, 0); diff --git a/src/native/metadata/ElfSectionNameTable.ts b/src/native/metadata/ElfSectionNameTable.ts index 0c47f02475..ee1da1c8b3 100644 --- a/src/native/metadata/ElfSectionNameTable.ts +++ b/src/native/metadata/ElfSectionNameTable.ts @@ -1,7 +1,8 @@ -import { inflateSync } from "node:zlib"; +import { constants, inflateSync, zstdDecompressSync } from "node:zlib"; const SHF_COMPRESSED = 0x800n; const ELFCOMPRESS_ZLIB = 1; +const ELFCOMPRESS_ZSTD = 2; export type ElfSectionNameTableResult = | { @@ -37,7 +38,10 @@ export const decodeElfSectionNameTable = ( const compressionType = little ? bytes.readUInt32LE(0) : bytes.readUInt32BE(0); - if (compressionType !== ELFCOMPRESS_ZLIB) + if ( + compressionType !== ELFCOMPRESS_ZLIB && + compressionType !== ELFCOMPRESS_ZSTD + ) return { state: "unsupported", compressionType }; const declaredSize = @@ -55,13 +59,20 @@ export const decodeElfSectionNameTable = ( const expected = Number(declaredSize); let decoded: Buffer; try { - decoded = inflateSync(bytes.subarray(headerBytes), { - maxOutputLength: Math.max(1, expected), - }); + const payload = bytes.subarray(headerBytes); + const maxOutputLength = Math.max(1, expected); + decoded = + compressionType === ELFCOMPRESS_ZSTD + ? zstdDecompressSync(payload, { + maxOutputLength, + // Retain Zstandard's default maximum decoder window of 128 MiB. + params: { [constants.ZSTD_d_windowLogMax]: 27 }, + }) + : inflateSync(payload, { maxOutputLength }); } catch (cause: unknown) { return { state: "malformed", - message: `ELF compressed section-name table could not be decompressed: ${cause instanceof Error ? cause.message : "invalid zlib data"}`, + message: `ELF compressed section-name table could not be decompressed: ${cause instanceof Error ? cause.message : "invalid compressed data"}`, }; } if (decoded.length !== expected) diff --git a/tests/acceptance/elfNotesCli.test.ts b/tests/acceptance/elfNotesCli.test.ts index ae538dd556..02bca6dddf 100644 --- a/tests/acceptance/elfNotesCli.test.ts +++ b/tests/acceptance/elfNotesCli.test.ts @@ -31,24 +31,28 @@ cliTest("reports a GNU build-id through the public CLI", async ({ cli }) => { }); cliTest( - "resolves section note names from a compressed string table through the CLI", + "resolves section note names from zlib and Zstandard string tables through the CLI", async ({ cli }) => { const root = await createTestTempDirectory("rea-elf-notes-compressed-cli-"); - const path = join(root, "program"); - await writeFile(path, compressedElfNoteFixture()); - const result = await cli.run({ - arguments: ["inspect-elf-notes", path, "--json"], - }); - expect(result.exitCode, result.stderr).toBe(0); - const evidence = toolContract("inspect_elf_notes").outputSchema.parse( - result.json, - ); - expect(evidence.normalized_result.tables[0]?.name).toBe( - ".note.gnu.build-id", - ); - expect(evidence.normalized_result.entries[0]?.gnu?.build_id_hex).toBe( - "0123456789abcdef0123456789abcdef01234567", - ); + for (const compressionType of [1, 2]) { + const path = join(root, `program-${String(compressionType)}`); + const bytes = compressedElfNoteFixture(64, true, compressionType); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_elf_notes").outputSchema.parse( + result.json, + ); + expect(evidence.normalized_result.tables[0]?.name).toBe( + ".note.gnu.build-id", + ); + expect(evidence.normalized_result.entries[0]?.gnu?.build_id_hex).toBe( + "0123456789abcdef0123456789abcdef01234567", + ); + expect(await readFile(path)).toEqual(bytes); + } }, ); diff --git a/tests/acceptance/rustBinaryCli.test.ts b/tests/acceptance/rustBinaryCli.test.ts index 96fe60517f..1a4cb4a718 100644 --- a/tests/acceptance/rustBinaryCli.test.ts +++ b/tests/acceptance/rustBinaryCli.test.ts @@ -267,7 +267,8 @@ cliTest( ); for (const fixture of [ { name: "zlib", type: 1, supported: true }, - { name: "unknown", type: 2, supported: false }, + { name: "zstd", type: 2, supported: true }, + { name: "unknown", type: 3, supported: false }, ]) { const path = join(root, `${fixture.name}.elf`); await writeFile( @@ -297,7 +298,7 @@ cliTest( } else { expect(report.auditable_metadata).toBeNull(); expect(report.limitations.join("\n")).toContain( - "ELF section names are unavailable because compression type 2 is unsupported", + "ELF section names are unavailable because compression type 3 is unsupported", ); expect(report.limitations.join("\n")).toContain( "The presence of .dep-v0 could not be determined", diff --git a/tests/boundary/go/goBinaryProvider.test.ts b/tests/boundary/go/goBinaryProvider.test.ts index 51f44bdef5..077f2ecbf9 100644 --- a/tests/boundary/go/goBinaryProvider.test.ts +++ b/tests/boundary/go/goBinaryProvider.test.ts @@ -104,7 +104,7 @@ it("reports unreadable ELF names alongside the no-record limitation", async () = const nameTableHeader = 256 + 64; bytes.writeBigUInt64LE(0x800n, nameTableHeader + 8); bytes.writeBigUInt64LE(24n, nameTableHeader + 32); - bytes.writeUInt32LE(2, 768); + bytes.writeUInt32LE(3, 768); const root = await createTestTempDirectory("rea-go-unknown-section-names-"); const path = join(root, "unknown-names.elf"); await writeFile(path, bytes); @@ -116,7 +116,7 @@ it("reports unreadable ELF names alongside the no-record limitation", async () = "No recognized Go build-info record was found in the inspected image; absent metadata does not establish that the binary is not Go.", ); expect(result.value.limitations).toContain( - "ELF section names are unavailable because compression type 2 is unsupported", + "ELF section names are unavailable because compression type 3 is unsupported", ); expect(result.value).not.toHaveProperty("section_names_limitation"); }); diff --git a/tests/boundary/rust/rustBinaryImage.test.ts b/tests/boundary/rust/rustBinaryImage.test.ts index 96e7ae99c8..3a7c8d83b5 100644 --- a/tests/boundary/rust/rustBinaryImage.test.ts +++ b/tests/boundary/rust/rustBinaryImage.test.ts @@ -221,25 +221,31 @@ it("returns null metadata when the dependency section is absent", () => { ]); }); -it("reads dependency metadata through a compressed ELF section-name table", () => { - const image = readRustBinaryImage( - compressRustElfSectionNames(createRustImage({ format: "elf" })), - ); - expect(image.auditable_metadata).toMatchObject({ - section_name: ".dep-v0", - file_offset: 1024, - packages, - dependency_edges: [{ from_index: 1, to_index: 0 }], - }); -}); +it.each([1, 2])( + "reads dependency metadata through ELF section-name compression %i", + (compressionType) => { + const image = readRustBinaryImage( + compressRustElfSectionNames( + createRustImage({ format: "elf" }), + compressionType, + ), + ); + expect(image.auditable_metadata).toMatchObject({ + section_name: ".dep-v0", + file_offset: 1024, + packages, + dependency_edges: [{ from_index: 1, to_index: 0 }], + }); + }, +); it("keeps dependency presence unknown for unsupported ELF name compression", () => { const image = readRustBinaryImage( - compressRustElfSectionNames(createRustImage({ format: "elf" }), 2), + compressRustElfSectionNames(createRustImage({ format: "elf" }), 3), ); expect(image.auditable_metadata).toBeNull(); expect(image.section_names_limitation).toMatch( - /compression type 2 is unsupported/u, + /compression type 3 is unsupported/u, ); expect(image.rustc_commit?.hashes).toEqual([ { hash: RUSTC_HASH, file_offset: 2048 }, diff --git a/tests/fixtures/rust/image.ts b/tests/fixtures/rust/image.ts index 7afd36739e..90bab317b2 100644 --- a/tests/fixtures/rust/image.ts +++ b/tests/fixtures/rust/image.ts @@ -1,4 +1,4 @@ -import { deflateSync } from "node:zlib"; +import { deflateSync, zstdCompressSync } from "node:zlib"; /** zlib payload matching the embedded dependency JSON shape. */ export const rustDependencyPayload = ( @@ -31,7 +31,8 @@ export const compressRustElfSectionNames = ( const names = Buffer.from( bytes.subarray(namesOffset, namesOffset + namesSize), ); - const compressed = deflateSync(names); + const compressed = + compressionType === 2 ? zstdCompressSync(names) : deflateSync(names); const compressionHeader = Buffer.alloc(24); compressionHeader.writeUInt32LE(compressionType, 0); compressionHeader.writeBigUInt64LE(BigInt(names.length), 8);