fix(native): normalize otool line endings and strip plist JSON BOM (#533)

This commit is contained in:
morluto 2026-10-05 15:28:31 +08:00 committed by GitHub
parent 0f77ada55e
commit 3e4d4d93fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 50 additions and 2 deletions

View File

@ -44,8 +44,19 @@ interface ParsedLoadCommands {
readonly flags: Set<string>;
}
/**
* otool emits LF, but a capture replayed from another host or toolchain can
* carry CRLF. The section/command boundaries below match a bare `\n`, so a
* stray `\r` would swallow the terminator and silently empty `sections` while
* leaking section fields into the command. Normalizing once at the boundary
* keeps every parser below working on one line ending.
*/
const normalizeLineEndings = (output: string): string =>
output.replaceAll("\r\n", "\n");
/** Parse stable fields from `otool -l`, preserving unknown command fields. */
export const parseOtoolLoadCommands = (output: string) => {
export const parseOtoolLoadCommands = (raw: string) => {
const output = normalizeLineEndings(raw);
const headerTokens = parseHeaderTokens(output);
const state = createLoadCommandState(headerTokens);
for (const block of output.split(/(?=Load command \d+)/u))

View File

@ -4,7 +4,8 @@ const plistObject = z.record(z.string(), z.unknown());
/** Parse plutil JSON output and project stable bundle metadata. */
export const parsePlistJson = (output: string) => {
const value: unknown = JSON.parse(output);
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
const value: unknown = JSON.parse(output.replace(/^\uFEFF/u, ""));
const object = plistObject.safeParse(value);
const field = (name: string): string | null => {
if (!object.success) return null;

View File

@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import { parsePlistJson } from "../../../../src/native/parsers/plist.js";
import { nativeFixture } from "../../../fixtures/nativeCommands.js";
const toCrlf = (value: string): string => value.replaceAll("\n", "\r\n");
it("keeps section blocks separate under CRLF captures", async () => {
const lf = parseOtoolLoadCommands(await nativeFixture("otool-load.txt"));
const crlf = parseOtoolLoadCommands(
toCrlf(await nativeFixture("otool-load.txt")),
);
expect(crlf.segments).toMatchObject(lf.segments);
const crlfText = crlf.segments.find(({ name }) => name === "__TEXT");
expect(crlfText?.sections.length).toBeGreaterThan(0);
expect(crlfText?.sections.map(({ name }) => name)).toContain("__text");
});
it("does not leak section fields into command fields under CRLF captures", async () => {
const crlf = parseOtoolLoadCommands(
toCrlf(await nativeFixture("otool-load.txt")),
);
const segment = crlf.commands.find(
(command) => command.kind === "LC_SEGMENT_64",
);
expect(segment?.fields.sectname).toBeUndefined();
expect(segment?.fields["time stamp"]).toBeUndefined();
});
it("accepts a byte-order mark before plist JSON", () => {
const parsed = parsePlistJson(
'\uFEFF{"CFBundleIdentifier":"com.owned.fixture"}',
);
expect(parsed.bundle.identifier).toBe("com.owned.fixture");
});