fix(native): keep lipo path echoes out of FAT records (#2605)

* fix(native): keep lipo path echoes out of FAT records

Parse FAT records only after separating the exact echoed operand path. Pass the same lipo operand at all three consumers and cover multiline paths through parser, slice-reader and compiled CLI regressions.

* fix(native): use parsed lipo framing for container offsets

* test(native): use explicit FAT fixtures for CLI regressions
This commit is contained in:
FengCi 2026-10-13 21:16:49 +08:00 committed by GitHub
parent 11268fe241
commit 59d33a2733
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 347 additions and 106 deletions

View File

@ -8,7 +8,7 @@ import {
type NativeCommandRunner,
} from "../native/CommandRunner.js";
import {
parseLipoArchitectures,
parseLipoOutput,
type LipoArchitecture,
} from "../native/parsers/lipo.js";
import type { MachoSlice } from "../domain/apple/dylibResolution.js";
@ -114,7 +114,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
};
await this.#verifySource(source);
const fileSize = source.initial.size;
const architectures = parseLipoArchitectures(captured.value.stdout);
const { architectures } = parseLipoOutput(captured.value.stdout, this.path);
const structural = await readStructuralSlices(source.handle, fileSize);
await this.#verifySource(source);
if (structural.status === "malformed")

View File

@ -73,7 +73,7 @@ import {
} from "./SignatureTargetBinding.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoArchitectures } from "./parsers/lipo.js";
import { parseLipoOutput } from "./parsers/lipo.js";
import {
parsePlistJson,
parsePlistXml,
@ -507,7 +507,10 @@ class NativeMacOSClient implements AnalysisClient {
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const architectures = parseLipoArchitectures(capture.value.stdout);
const { architectures } = parseLipoOutput(
capture.value.stdout,
this.target.path,
);
const result = listArchitecturesSchema.parse({
architectures: {
items: architectures,

View File

@ -14,10 +14,7 @@ import {
selectMachoArchitectureName,
} from "./MachoArchitectureSelection.js";
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
import {
parseLipoArchitectures,
type LipoArchitecture,
} from "./parsers/lipo.js";
import { parseLipoOutput, type LipoArchitecture } from "./parsers/lipo.js";
import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
import { parseOtoolLoadCommands } from "./parsers/otool.js";
import { reconcileMachoUuid } from "./parsers/machoUuid.js";
@ -74,7 +71,10 @@ export const inspectNativeMacho = async (
}
const lipo = captures.find(({ tool }) => tool === "lipo");
if (lipo === undefined) throw new TypeError("Missing lipo capture");
const architectures = parseLipoArchitectures(lipo.stdout);
const { format, architectures } = parseLipoOutput(
lipo.stdout,
context.target.path,
);
const selection = selectArchitecture(context.target, architectures);
if (selection.status === "absent")
return err(
@ -145,7 +145,7 @@ export const inspectNativeMacho = async (
const containerOffset =
selectedArchitectureName === null
? undefined
: /^Non-fat file:/mu.test(lipo.stdout)
: format === "thin"
? 0
: (architectures.find(({ name }) => name === selectedArchitectureName)
?.file_offset ?? undefined);
@ -185,7 +185,7 @@ const normalizeMacho = (
toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation,
additionalLimitations: readonly string[],
load: ReturnType<typeof parseOtoolLoadCommands>,
architectures: ReturnType<typeof parseLipoArchitectures>,
architectures: readonly LipoArchitecture[],
artifactPath: string,
selectedArchitecture: {
readonly name: string | null;

View File

@ -1,10 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseLipoArchitectures } from "./lipo.js";
import { parseLipoOutput } from "./lipo.js";
const fatRecords = `fat_magic 0xcafebabe
nfat_arch 2
architecture arm64
cputype CPU_TYPE_ARM64
cpusubtype CPU_SUBTYPE_ARM64_ALL
capabilities 0x0
offset 4096
size 32
align 2^12 (4096)
architecture x86_64
cputype CPU_TYPE_X86_64
cpusubtype CPU_SUBTYPE_X86_64_ALL
capabilities 0x0
offset 8192
size 32
align 2^12 (4096)
`;
describe("lipo architecture facts", () => {
it("normalizes symbolic CPU fields and arm64e capability bits", () => {
const architectures = parseLipoArchitectures(`Fat header in: /bin/ls
const { format, architectures } = parseLipoOutput(`Fat header in: /bin/ls
fat_magic 0xcafebabe
nfat_arch 2
architecture x86_64
@ -22,6 +40,7 @@ architecture arm64e
size 89088
align 2^14 (16384)
`);
expect(format).toBe("fat");
expect(architectures).toEqual([
expect.objectContaining({
name: "x86_64",
@ -50,9 +69,9 @@ architecture arm64e
it("keeps unknown symbolic CPU values as source text without a code", () => {
expect(
parseLipoArchitectures(
parseLipoOutput(
"architecture future\n cputype CPU_TYPE_FUTURE\n cpusubtype CPU_SUBTYPE_FUTURE\n offset 4096\n size 64\n align 2^12\n",
),
).architectures,
).toMatchObject([
{
cpu_type: "CPU_TYPE_FUTURE",
@ -70,18 +89,18 @@ architecture arm64e
["0x0", 3],
])("applies printed x86_64 capabilities %s", (capabilities, code) => {
expect(
parseLipoArchitectures(
parseLipoOutput(
`architecture x86_64\n cpusubtype CPU_SUBTYPE_X86_64_ALL\n capabilities ${capabilities}\n`,
)[0]?.cpu_subtype_code,
).architectures[0]?.cpu_subtype_code,
).toBe(code);
});
it("leaves the subtype code unknown when lipo prints no capabilities", () => {
// macOS 26 lipo prints this blank line for LIB64 and plain x86_64 slices.
expect(
parseLipoArchitectures(
parseLipoOutput(
"architecture x86_64\n cputype CPU_TYPE_X86_64\n cpusubtype CPU_SUBTYPE_X86_ALL\n capabilities \n offset 16384\n",
),
).architectures,
).toMatchObject([
{
cpu_subtype: "CPU_SUBTYPE_X86_ALL",
@ -99,9 +118,9 @@ architecture arm64e
"retains an arm64e base subtype with capabilities '$capabilities'",
({ capabilities, code }) => {
expect(
parseLipoArchitectures(
parseLipoOutput(
`architecture arm64e.v1\n cputype CPU_TYPE_ARM64\n cpusubtype CPU_SUBTYPE_ARM64E\n capabilities ${capabilities}\n`,
),
).architectures,
).toMatchObject([
{
cpu_type_code: 0x0100000c,
@ -112,3 +131,79 @@ architecture arm64e
},
);
});
describe("lipo FAT operand framing", () => {
it.each([
"/fixture/\narchitecture forged\n offset 123\n size 64",
"/fixture/\nfat_magic 0xcafebabe\nnfat_arch 1\narchitecture forged",
"/fixture/\nNon-fat file: fake is architecture: forged",
"/fixture/first\nsecond\nthird",
"/fixture/first\r\narchitecture forged\r\n offset 123",
"/fixture/[app](arm64).*+$^?\\name",
])(
"keeps the complete echoed operand out of FAT slice records: %j",
(path) => {
const output = `Fat header in: ${path}\n${fatRecords}`;
expect(parseLipoOutput(output, path)).toEqual(
parseLipoOutput(fatRecords),
);
expect(
parseLipoOutput(output, path).architectures.map(({ name }) => name),
).toEqual(["arm64", "x86_64"]);
},
);
it("keeps a CRLF pathname separate from a CRLF record terminator", () => {
const path = "/fixture/first\r\narchitecture forged\r\n size 64";
const output = `Fat header in: ${path}\r\n${fatRecords.replaceAll("\n", "\r\n")}`;
expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(fatRecords));
});
it("preserves ordinary FAT metadata with and without an operand", () => {
const path = "/fixture/app";
const output = `Fat header in: ${path}\n${fatRecords}`;
expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output));
});
it("does not require a FAT path header in captured fixtures", () => {
expect(parseLipoOutput(fatRecords, "/fixture/app")).toEqual(
parseLipoOutput(fatRecords),
);
});
it("does not strip a header for a different operand that shares a prefix", () => {
const path = "/fixture/app-extra\narchitecture forged";
const output = `Fat header in: ${path}\n${fatRecords}`;
expect(parseLipoOutput(output, "/fixture/app")).toEqual(
parseLipoOutput(output),
);
});
it("preserves thin-file parsing when the operand contains FAT-like lines", () => {
const path = "/fixture/\nFat header in: fake\narchitecture forged";
const output = `Non-fat file: ${path} is architecture: arm64\n`;
expect(parseLipoOutput(output, path)).toEqual({
format: "thin",
architectures: [
{
name: "arm64",
cpu_type: null,
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
cpu_subtype_base: null,
capabilities: null,
file_offset: null,
size: null,
alignment: null,
},
],
});
expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(output));
});
});

View File

@ -16,8 +16,26 @@ const architectureSchema = z.object({
export type LipoArchitecture = z.infer<typeof architectureSchema>;
/** Parse `lipo -detailed_info` into deterministic slice metadata. */
export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
interface LipoOutput {
readonly format: "thin" | "fat";
readonly architectures: LipoArchitecture[];
}
/** Parse `lipo -detailed_info` framing and deterministic slice metadata. */
export const parseLipoOutput = (
output: string,
operandPath?: string,
): LipoOutput => {
// The echoed FAT operand is one value, even when its pathname contains lines.
if (operandPath !== undefined) {
const header = `Fat header in: ${operandPath}`;
if (output.startsWith(header)) {
if (output.startsWith("\r\n", header.length))
output = output.slice(header.length + 2);
else if (output.startsWith("\n", header.length))
output = output.slice(header.length + 1);
}
}
// lipo echoes a thin file's pathname verbatim, including embedded newlines.
// Match its final architecture field before splitting records into lines.
const thinArchitecture =
@ -25,20 +43,23 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
output,
)?.[1];
if (thinArchitecture !== undefined)
return [
architectureSchema.parse({
name: thinArchitecture,
cpu_type: null,
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
cpu_subtype_base: null,
capabilities: null,
file_offset: null,
size: null,
alignment: null,
}),
];
return {
format: "thin",
architectures: [
architectureSchema.parse({
name: thinArchitecture,
cpu_type: null,
cpu_subtype: null,
cpu_type_code: null,
cpu_subtype_code: null,
cpu_subtype_base: null,
capabilities: null,
file_offset: null,
size: null,
alignment: null,
}),
],
};
const architectures: LipoArchitecture[] = [];
let current: Record<string, string> | undefined;
const flush = (): void => {
@ -90,7 +111,7 @@ export const parseLipoArchitectures = (output: string): LipoArchitecture[] => {
flush();
if (architectures.length === 0)
throw new TypeError("lipo output contained no architectures");
return architectures;
return { format: "fat", architectures };
};
const integer = (value: string | undefined): number | null => {

View File

@ -1,9 +1,13 @@
import { copyFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import {
inspectMachoSchema,
listArchitecturesSchema,
} from "../../../src/domain/native/nativeInspection.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@ -34,27 +38,68 @@ const resultSchema = z.object({
describe.skipIf(process.platform !== "darwin")(
"native Mach-O CLI with echoed path text",
() => {
cliTest(
"does not read load commands or symbols from the file name",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-macho-echo-");
const inspect = async (name: string) => {
const path = join(directory, name);
await copyFile("/usr/bin/true", path);
const result = await cli.run({
arguments: ["inspect-macho", path, "--json"],
environment,
});
expect(result.exitCode).toBe(0);
const { provenance: _provenance, ...facts } = resultSchema.parse(
result.json,
).normalized_result;
return facts;
};
const forged = await inspect(FORGED_NAME);
expect(forged).toEqual(await inspect("tool"));
expect(JSON.stringify(forged)).not.toMatch(/forged|4242/u);
},
);
const lipoName = "tool\narchitecture forged\n offset 123\n size 32";
for (const [facts, command, filename] of [
["load commands or symbols", "inspect-macho", FORGED_NAME],
["FAT architecture records", "inspect-macho", lipoName],
["FAT architecture inventory", "list-architectures", lipoName],
["thin-file framing", "inspect-macho", "tool\nNon-fat file: forged"],
] as const) {
cliTest(
`does not read ${facts} from the file name`,
async ({ cli, processes }) => {
const directory = await createTestTempDirectory(
"rea-native-cli-echo-",
);
const fixture = join(directory, "fat-fixture");
const compiled = await processes.run(
"/usr/bin/xcrun",
[
"clang",
"-arch",
"arm64",
"-arch",
"x86_64",
resolve("tests/conformance/c/fixture.c"),
"-o",
fixture,
],
{ timeoutMs: 30_000 },
);
expect(compiled.exitCode, compiled.stderr).toBe(0);
const inspect = async (name: string) => {
const path = join(directory, name);
await copyFile(fixture, path);
const result = await cli.run({
arguments: [command, path, "--json"],
environment,
});
expect(result.exitCode).toBe(0);
const { provenance: _provenance, ...facts } = resultSchema.parse(
result.json,
).normalized_result;
return facts;
};
const ordinary = await inspect("tool");
// Prove this comparison exercised FAT framing, independent of host binaries.
const architectures =
listArchitecturesSchema.shape.architectures.parse(
ordinary.architectures,
);
expect(architectures.total).toBeGreaterThan(1);
if (command === "inspect-macho") {
const selected =
inspectMachoSchema.shape.selected_architecture.parse(
ordinary.selected_architecture,
);
expect(selected.container_file_offset).toBeGreaterThan(0);
}
const forged = await inspect(filename);
expect(forged).toEqual(ordinary);
expect(JSON.stringify(forged)).not.toMatch(/forged|4242/u);
},
60_000,
);
}
},
);

View File

@ -1,10 +1,12 @@
import { readFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it, onTestFinished } from "vitest";
import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js";
import { XcrunCommandRunner } from "../../../../src/native/CommandRunner.js";
import { DarwinProcessOwnershipInspectionError } from "../../../../src/process/DarwinProcessRunTokenReader.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
/**
* Read a 32-bit FAT table directly. The host's /bin/ls changes between macOS
@ -42,6 +44,56 @@ describe("Mach-O slice reader with system lipo", () => {
});
});
it.skipIf(process.platform !== "darwin")(
"keeps FAT slice records separate from the echoed source pathname",
async () => {
const directory = await createTestTempDirectory("rea-lipo-fat-path-");
const path = join(
directory,
"source\narchitecture forged\n offset 123\n size 32",
);
const bytes = Buffer.alloc(8224);
bytes.writeUInt32BE(0xca_fe_ba_be, 0);
bytes.writeUInt32BE(2, 4);
const slices = [
{ cpu: 0x0100_000c, subtype: 0, offset: 4096 },
{ cpu: 0x0100_0007, subtype: 3, offset: 8192 },
];
slices.forEach(({ cpu, subtype, offset }, index) => {
const entry = 8 + index * 20;
bytes.writeUInt32BE(cpu, entry);
bytes.writeUInt32BE(subtype, entry + 4);
bytes.writeUInt32BE(offset, entry + 8);
bytes.writeUInt32BE(32, entry + 12);
bytes.writeUInt32BE(12, entry + 16);
bytes.writeUInt32LE(0xfeed_facf, offset);
bytes.writeUInt32LE(cpu, offset + 4);
bytes.writeUInt32LE(subtype, offset + 8);
bytes.writeUInt32LE(1, offset + 12);
});
await writeFile(path, bytes);
const reader = new MachOSliceArtifactReader(path, {});
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(
entries.map(({ byteOffset, declaredSize }) => [
byteOffset,
declaredSize,
]),
).toEqual([
[4096, 32],
[8192, 32],
]);
expect(entries.every(({ path }) => !path.includes("forged"))).toBe(
true,
);
} finally {
await reader.close();
}
},
);
it.skipIf(process.platform !== "darwin")(
"checks real /bin/ls slice ranges against its FAT table",
async () => {

View File

@ -17,7 +17,7 @@ import {
type NativeCommandRunner,
} from "../../../../src/native/CommandRunner.js";
import { err, ok } from "../../../../src/domain/result.js";
import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js";
import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js";
import { parseCodeSignature } from "../../../../src/native/parsers/codesign.js";
import { prepareProcessOwnershipInspection } from "../../../../src/process/ProcessOwnershipObservation.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
@ -960,15 +960,17 @@ describe("native macOS provider failures and parsing", () => {
});
it("parses thin and universal lipo fixtures", async () => {
expect(
parseLipoArchitectures(await fixture("lipo-thin.txt")),
).toMatchObject([{ name: "arm64" }]);
expect(parseLipoArchitectures(await fixture("lipo-fat.txt"))).toMatchObject(
[
expect(parseLipoOutput(await fixture("lipo-thin.txt"))).toMatchObject({
format: "thin",
architectures: [{ name: "arm64" }],
});
expect(parseLipoOutput(await fixture("lipo-fat.txt"))).toMatchObject({
format: "fat",
architectures: [
{ name: "x86_64", file_offset: 16384, size: 4096 },
{ name: "arm64", file_offset: 32768, size: 8192 },
],
);
});
});
it("rejects trailing text in lipo numeric fields instead of truncating", () => {
@ -980,7 +982,7 @@ describe("native macOS provider failures and parsing", () => {
" size 0x10",
" align 2^14 (16384)",
].join("\n");
expect(parseLipoArchitectures(output)).toMatchObject([
expect(parseLipoOutput(output).architectures).toMatchObject([
{ name: "arm64", file_offset: null, size: null, alignment: 16384 },
]);
});
@ -993,10 +995,11 @@ describe("native macOS provider failures and parsing", () => {
" align 2^14 (16384)",
].join("\n");
expect(
parseLipoArchitectures(output.replace("(16384)", "(8192)"))[0]?.alignment,
parseLipoOutput(output.replace("(16384)", "(8192)")).architectures[0]
?.alignment,
).toBeNull();
expect(
parseLipoArchitectures(output.replace("2^14 (16384)", "2^14"))[0]
parseLipoOutput(output.replace("2^14 (16384)", "2^14")).architectures[0]
?.alignment,
).toBe(16384);
});

View File

@ -157,6 +157,50 @@ it.each([
},
);
it.each([
["ordinary", "/owned/universal", "\n"],
["LF Non-fat text", "/owned/\nNon-fat file: forged", "\n"],
["CRLF Non-fat text", "/owned/\r\nNon-fat file: forged", "\r\n"],
])(
"projects universal segment evidence into the selected slice's container offsets with %s path framing",
async (_description, path, lineEnding) => {
const records = (await nativeFixture("lipo-fat.txt")).replace(
/^Fat header in:[^\r\n]*\r?\n/u,
"",
);
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner({
lipo: `Fat header in: ${path}${lineEnding}${records.replaceAll("\n", lineEnding)}`,
}),
"darwin",
).createClient(nativeMachoTarget(path));
const result = await client.execute("inspect_macho", {});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
selected_architecture: { container_file_offset: 32768 },
});
expect(result.value.locations).toContainEqual({
kind: "file-offset-range",
start: 32768,
end: 40960,
});
expect(result.value.locations).toContainEqual({
kind: "file-offset",
offset: 36864,
});
expect(result.value.locations).not.toContainEqual({
kind: "file-offset-range",
start: 0,
end: 8192,
});
// Normalized tool fields keep their slice-relative meaning.
const normalized = inspectMachoSchema.parse(result.value.result);
expect(normalized.segments.items[0]?.file_offset).toBe(0);
expect(normalized.entrypoints.items[0]?.file_offset).toBe(4096);
},
);
it.each([
{ capture: "calculator.txt", selected: "arm64e" },
{ capture: "shortcut-droplet.txt", selected: "arm64" },
@ -187,34 +231,6 @@ it.each([
},
);
it("projects universal segment evidence into the selected slice's container offsets", async () => {
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner(),
"darwin",
).createClient(nativeMachoTarget("/owned/universal"));
const result = await client.execute("inspect_macho", {});
if (!result.ok) throw result.error;
expect(result.value.locations).toContainEqual({
kind: "file-offset-range",
start: 32768,
end: 40960,
});
expect(result.value.locations).toContainEqual({
kind: "file-offset",
offset: 36864,
});
expect(result.value.locations).not.toContainEqual({
kind: "file-offset-range",
start: 0,
end: 8192,
});
// Normalized tool fields keep their slice-relative meaning.
const normalized = inspectMachoSchema.parse(result.value.result);
expect(normalized.segments.items[0]?.file_offset).toBe(0);
expect(normalized.entrypoints.items[0]?.file_offset).toBe(4096);
});
it.each([
{
description: "thin",

View File

@ -2,7 +2,7 @@ import { expect, it } from "vitest";
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import { parsePlistJson } from "../../../../src/native/parsers/plist.js";
import { parseLipoArchitectures } from "../../../../src/native/parsers/lipo.js";
import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js";
import { nativeFixture } from "../../../fixtures/nativeCommands.js";
const toCrlf = (value: string): string => value.replaceAll("\n", "\r\n");
@ -15,7 +15,10 @@ it.each([
"retains the reported thin architecture when lipo echoes filename %j",
(filename) => {
const output = `input file /owned/${filename} is not a fat file\nNon-fat file: /owned/${filename} is architecture: arm64\n`;
expect(parseLipoArchitectures(output)).toMatchObject([{ name: "arm64" }]);
expect(parseLipoOutput(output)).toMatchObject({
format: "thin",
architectures: [{ name: "arm64" }],
});
},
);
@ -38,10 +41,13 @@ it("does not mistake fat slice output for a thin Non-fat file record", () => {
" align 2^12 (4096)",
"",
].join("\n");
expect(parseLipoArchitectures(output)).toMatchObject([
{ name: "arm64", file_offset: 16384 },
{ name: "x86_64", file_offset: 16793600 },
]);
expect(parseLipoOutput(output)).toMatchObject({
format: "fat",
architectures: [
{ name: "arm64", file_offset: 16384 },
{ name: "x86_64", file_offset: 16793600 },
],
});
});
it("keeps section blocks separate under CRLF captures", async () => {