mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(elf): decode Zstandard section-name tables (#6987)
* feat(elf): decode Zstandard section-name tables * test(verifiers): follow shared command output and identity
This commit is contained in:
1 parent
85bc8c8977
commit
fcebd79ed8
10 files changed
+180
-91
No files matched your search
+4
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Vendored
+3
-2
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user