fix(native): preserve parsed section and architecture facts

Classify lipo parse failures once for native and artifact consumers, retain valid read-only ELF ranges independently of optional section names, and map IL2CPP reader architecture through the existing CPU identity owner before Mach-O parsing.

Regression-first checks reproduced malformed lipo output, lost unnamed-section compiler paths, and the amd64/x86_64 adapter mismatch. Focused parser, artifact, Rust CLI/MCP, Go, and IL2CPP controls passed; authored fixtures do not establish real-provider behavior.

Fixes #5982

Fixes #3397
This commit is contained in:
morluto committed 2026-10-24 07:28:09 +08:00
1 parent 3d4f55652b
commit 231e458246
14 files changed
+301 -36

No files matched your search

+4 -2
View File
@@ -147,8 +147,10 @@ in [CLI and Evidence](cli.md#run-a-command). Compact delivery requires
preserves every decoded row for subsequent queries without the original file.
`--binary PATH` links method addresses and `--architecture arm64|amd64` selects
exactly one slice of a universal Mach-O. Multiple slices with the same
architecture label are refused as `ambiguous-slice-selection`, including
exactly one slice of a universal Mach-O. The report retains the native reader's
`amd64` spelling for x86-64; loader CPU checks use the corresponding `x86_64`
identity. Multiple slices with the same architecture label are refused as
`ambiguous-slice-selection`, including
distinct ARM subtypes that both map to `arm64`. On macOS, use `lipo -info`
to enumerate exact subtypes, then extract the intended subtype into a separate
file, for example `lipo universal -thin arm64e -output thin-arm64e`. Inspect
+2
View File
@@ -433,6 +433,8 @@ Available CPU type, subtype/capability bits, byte range, and alignment remain
checked against the source bytes. Inventory records source ranges and hashes, and
extraction copies those ranges without running the target. This does not
expand any analysis provider's supported architectures.
Malformed successful `lipo` output reports a format failure with its parse
diagnostic.
Apple lipo's hidden ARM64 compatibility row is retained separately from the
raw `nfat_arch` count and admitted only from its extra FAT table slot when its
in-range Mach-O header matches the row. `list_architectures` preserves the
+10 -3
View File
@@ -117,9 +117,16 @@ removed the field entirely, as recorded in its
[changelog](https://github.com/rust-secure-code/cargo-auditable/blob/v0.5.0/CHANGELOG.md).
`rustc_commit` is present only when a `/rustc/<40-hex>/` path is found in
read-only data. For ELF, read-only non-executable `PT_LOAD` file ranges are
searched alongside read-only sections, so an optional missing section-header
table does not remove this search source. Its `evidence_kind` is `inferred`.
read-only data; its `evidence_kind` is `inferred`. For ELF, read-only
non-executable `PT_LOAD` file ranges are searched alongside read-only sections,
so an optional missing section-header
table does not remove this search source. An empty or unavailable section name
does not erase a section's validated read-only range: ELF defines the
[section-name index](https://gabi.xinuos.com/elf/03-sheader.html#section-header-table-entry)
separately from its type and flags, and
[string-table index zero](https://gabi.xinuos.com/elf/04-strtab.html) means no name.
REA preserves known empty names separately from unavailable names; an unavailable
name table still leaves `.dep-v0` presence unknown.
The hash is not a claim that that compiler produced the binary. Several
distinct hashes leave a single commit unknown.
@@ -248,6 +248,49 @@ describe("MachOSliceArtifactReader hidden architectures", () => {
});
describe("MachOSliceArtifactReader", () => {
it("classifies malformed successful lipo output at the reader boundary", async () => {
const root = await createTestTempDirectory("rea-slices-malformed-lipo-");
const binary = join(root, "fat");
await writeFile(
binary,
fatImage([
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
]),
);
const reader = new MachOSliceArtifactReader(
binary,
{},
{
run: async () =>
ok({
tool: "lipo",
executable: "/usr/bin/lipo",
executableSha256: "1".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: ["-detailed_info", binary],
stdout: "Fat header in: fixture\n",
stderr: "",
stdoutBytes: 23,
stderrBytes: 0,
exitCode: 0,
signal: null,
}),
},
);
onTestFinished(() => reader.close());
await expect(
reader.entries()[Symbol.asyncIterator]().next(),
).rejects.toMatchObject({
reason: "format",
message:
"lipo did not produce readable architecture records: lipo output contained no architectures",
cause: new TypeError("lipo output contained no architectures"),
});
});
it("keeps a borrowed source usable after idle and active cancelled slice reads", async () => {
const root = await createTestTempDirectory("rea-slices-cancelled-");
const binary = join(root, "fat");
+9 -2
View File
@@ -9,7 +9,7 @@ import {
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import {
parseLipoOutput,
parseLipoOutputResult,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
@@ -115,7 +115,14 @@ export class MachOSliceArtifactReader implements ArtifactReader {
};
await this.#verifySource(source);
const fileSize = source.initial.size;
const lipo = parseLipoOutput(captured.value.stdout, this.path);
const parsed = parseLipoOutputResult(captured.value.stdout, this.path);
if (!parsed.ok)
throw new ArtifactReaderFailure(
"format",
`lipo did not produce readable architecture records: ${parsed.error.message}`,
{ cause: parsed.error },
);
const lipo = parsed.value;
const { architectures } = lipo;
const structural = await readStructuralSlices(source.handle, fileSize);
await this.#verifySource(source);
+6 -1
View File
@@ -2,6 +2,7 @@ import {
readFatContainerHeader,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture.js";
import {
BinaryFormatFailure,
BinaryResourceFailure,
@@ -144,7 +145,11 @@ export function* openImageSteps(
let machoPointerFixups: PointerFixups | undefined;
if (container.format === "macho") {
try {
const layout = parseMachoLayout(image, container.architecture);
const layout = parseMachoLayout(
image,
binaryMetadataArchitecture(container.architecture) ??
container.architecture,
);
machoPointerFixups = parsePointerFixups(
image,
layout.fixups,
+1 -2
View File
@@ -67,7 +67,6 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => {
zeroFills.push({ address: address + size, size: memorySize - size });
}
for (const section of directory.sections) {
if (section.name === "") continue;
const range = declaredFileRange(bytes.length, section.offset, section.size);
const file_backed = section.type !== 8 && range.file_backed;
const compressed = (section.flags & 0x800n) !== 0n;
@@ -78,7 +77,7 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => {
(section.flags & 1n) === 0n &&
(section.flags & 4n) === 0n &&
(section.flags & 2n) !== 0n;
if (section.name === null && !read_only) continue;
if ((section.name === null || section.name === "") && !read_only) continue;
sections.push({
format: "elf",
name: section.name,
+1 -1
View File
@@ -10,7 +10,7 @@ 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. */
/** Identify an ELF, PE, or thin Mach-O image and its file sections. */
export const readBinaryContainer = (bytes: Buffer): BinaryContainer => {
if (bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
return readElfImage(bytes);
+34 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseLipoOutput } from "./lipo.js";
import { parseLipoCapture, parseLipoOutput } from "./lipo.js";
const fatRecords = `fat_magic 0xcafebabe
nfat_arch 2
@@ -20,6 +20,39 @@ architecture x86_64
align 2^12 (4096)
`;
it("retains native command capture when shared lipo parsing rejects records", () => {
const capture = {
tool: "lipo",
executable: "/usr/bin/lipo",
executableSha256: "1".repeat(64),
toolVersion: null,
versionReason: null,
arguments: ["-detailed_info", "/fixture/fat"],
stdout: "Fat header in: /fixture/fat\n",
stderr: "lipo emitted no slice rows",
stdoutBytes: 28,
stderrBytes: 26,
exitCode: 0,
signal: null,
};
const parsed = parseLipoCapture(capture, "inspect_macho", "/fixture/fat");
expect(parsed).toMatchObject({
ok: false,
error: {
operation: "inspect_macho",
cause: new TypeError("lipo output contained no architectures"),
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: 0,
signal: null,
},
},
});
});
describe("lipo architecture facts", () => {
it("preserves Apple's declared and hidden ARM64 architecture scopes", () => {
const output = parseLipoOutput(`fat_magic 0xcafebabe
+34 -24
View File
@@ -138,36 +138,46 @@ export const parseLipoOutput = (
};
};
/** The producing parser owns malformed tool-output classification for every adapter. */
/** Classify expected lipo parse failures once for all command adapters. */
export const parseLipoOutputResult = (
output: string,
operandPath?: string,
): Result<LipoOutput, TypeError | z.ZodError> => {
try {
return ok(parseLipoOutput(output, operandPath));
} catch (cause: unknown) {
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
throw cause;
return err(cause);
}
};
/** Translate classified parse failure into native command output failure. */
export const parseLipoCapture = (
capture: NativeCommandCapture,
operation: "inspect_macho" | "list_architectures",
operandPath: string,
): Result<LipoOutput, AnalysisOutputError> => {
try {
return ok(parseLipoOutput(capture.stdout, operandPath));
} catch (cause) {
if (!(cause instanceof TypeError) && !(cause instanceof z.ZodError))
throw cause;
return err(
new AnalysisOutputError(
operation,
"lipo did not produce readable architecture records",
{
cause,
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: false,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
const parsed = parseLipoOutputResult(capture.stdout, operandPath);
if (parsed.ok) return parsed;
return err(
new AnalysisOutputError(
operation,
"lipo did not produce readable architecture records",
{
cause: parsed.error,
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: false,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
),
);
}
},
),
);
};
const integer = (value: string | undefined): number | null => {
@@ -0,0 +1,59 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import { rustBinarySchema } from "../../src/domain/rust/rustBinary.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import {
RUSTC_HASH,
rustElfSectionNameFixture,
} from "../fixtures/rust/image.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest(
"finds compiler paths independently of ELF section names through CLI and stdio MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-rust-unnamed-sections-");
const { client, call } = await connectLocalToolsMcp();
for (const name of ["named", "empty", "unavailable", "writable"] as const) {
const bytes = rustElfSectionNameFixture(
name === "writable" ? "empty" : name,
);
if (name === "writable") bytes.writeBigUInt64LE(3n, 384 + 8);
const path = join(root, `${name}.elf`);
await writeFile(path, bytes);
const output = await cli.sharedCliRun!({
arguments: ["inspect-rust-binary", path, "--json"],
});
expect(output.exitCode, output.stderr).toBe(0);
const evidence = toolContract("inspect_rust_binary").outputSchema.parse(
output.json,
);
const report = rustBinarySchema.parse(evidence.normalized_result);
expect(report.artifact.sha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
expect(report.auditable_metadata).toBeNull();
expect(report.rustc_commit).toEqual(
name === "writable"
? null
: {
evidence_kind: "inferred",
hashes: [{ hash: RUSTC_HASH, file_offset: 2048 }],
},
);
if (name === "unavailable")
expect(report.limitations).toContainEqual(
expect.stringContaining("no section-name string table is declared"),
);
const response = await call("inspect_rust_binary", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(response.structuredContent).toEqual(evidence);
expect(await readFile(path)).toEqual(bytes);
}
await client.ping();
},
);
@@ -187,6 +187,37 @@ it("requires an explicit slice of a universal Mach-O and reads only that slice",
expect(failure(thin, "arm64").reason).toBe("slice-not-found");
});
it.each(["thin", "universal"] as const)(
"links a %s x86-64 Mach-O while retaining the reader's amd64 spelling",
(format) => {
const thin = createIl2cppBinaryFixture();
thin.writeUInt32LE(0x01000007, 4);
thin.writeUInt32LE(3, 8);
const bytes =
format === "thin"
? thin
: createUniversalFixture([
{ cpu: 0x0100000c, bytes: createIl2cppBinaryFixture() },
{ cpu: 0x01000007, bytes: thin },
]);
// Match the selected FAT declaration to CPU_SUBTYPE_X86_64_ALL in its header.
if (format === "universal") bytes.writeUInt32BE(3, 8 + 20 + 4);
const original = Buffer.from(bytes);
const linked = link(bytes, format === "thin" ? undefined : "amd64");
expect(linked).toMatchObject({
format: "macho",
architecture: "amd64",
code_gen_modules_field: FIXTURE_BINARY_BASE + 0x600n,
code_gen_modules_array: FIXTURE_BINARY_BASE + 0x500n,
});
expect(linked.method_addresses).toEqual(expectedAddresses);
expect(linked.return_types).toEqual(
link(createIl2cppBinaryFixture()).return_types,
);
expect(bytes).toEqual(original);
},
);
it.each<
[string, Il2cppBinaryFixtureOptions, string, "unsupported" | "malformed"]
>([
@@ -5,6 +5,7 @@ import {
createRustImageWithGoSection,
createRustHashOverflowImage,
compressRustElfSectionNames,
rustElfSectionNameFixture,
RUSTC_HASH,
rustDependencyPayload,
} from "../../fixtures/rust/image.js";
@@ -34,6 +35,60 @@ const packages = [
},
];
it.each([
["named", ".rodata"],
["empty", ""],
["unavailable", null],
] as const)(
"retains read-only ELF section bytes with a %s name",
(name, expectedName) => {
const bytes = rustElfSectionNameFixture(name);
const container = readBinaryContainer(bytes);
expect(container.load_regions?.every(({ write }) => write)).toBe(true);
expect(container.sections).toContainEqual(
expect.objectContaining({
format: "elf",
name: expectedName,
offset: 2048,
read_only: true,
file_backed: true,
}),
);
expect(readRustBinaryImage(bytes).rustc_commit?.hashes).toEqual([
{ hash: RUSTC_HASH, file_offset: 2048 },
]);
},
);
it.each([
["writable", 1, 3n],
["executable", 1, 6n],
["unallocated", 1, 0n],
["NOBITS", 8, 2n],
["NULL", 0, 2n],
] as const)(
"does not scan unnamed ELF %s sections for compiler paths",
(_kind, type, flags) => {
const bytes = rustElfSectionNameFixture("empty");
bytes.writeUInt32LE(type, 384 + 4);
bytes.writeBigUInt64LE(flags, 384 + 8);
expect(readBinaryContainer(bytes).sections).not.toContainEqual(
expect.objectContaining({ offset: 2048, read_only: true }),
);
expect(readRustBinaryImage(bytes).rustc_commit).toBeNull();
},
);
it("does not scan an unnamed ELF section whose declared range leaves the file", () => {
const bytes = rustElfSectionNameFixture("empty");
// The compiler path still exists at 2048, but its declared section no longer fits.
bytes.writeBigUInt64LE(BigInt(bytes.length), 384 + 32);
expect(readBinaryContainer(bytes).sections).not.toContainEqual(
expect.objectContaining({ read_only: true }),
);
expect(readRustBinaryImage(bytes).rustc_commit).toBeNull();
});
it.each(["elf", "pe"] as const)(
"reads a .dep-v0 section and an inferred rustc hash from %s",
(format) => {
+12
View File
@@ -81,6 +81,18 @@ export const createRustImage = (options: {
return bytes;
};
/** Read-only ELF data stays independent of its section-name spelling or availability. */
export const rustElfSectionNameFixture = (
name: "named" | "empty" | "unavailable",
): Buffer => {
const bytes = createRustImage({ format: "elf", omitSection: true });
// This fixture's read-only PROGBITS header is at 384. Its PT_LOAD is writable,
// so compiler-path recovery must use the section rather than segment fallback.
if (name === "empty") bytes.writeUInt32LE(0, 384);
if (name === "unavailable") bytes.writeUInt16LE(0, 62);
return bytes;
};
interface RustImageWrite {
readonly bytes: Buffer;
readonly little: (offset: number, value: number, width: 16 | 32 | 64) => void;