From 3e4d4d93fdc3eb5fdffda3a8dc8bec43201cdd98 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:28:31 +0800 Subject: [PATCH] fix(native): normalize otool line endings and strip plist JSON BOM (#533) --- src/native/parsers/otool.ts | 13 ++++++- src/native/parsers/plist.ts | 3 +- .../native/nativeTextRobustness.test.ts | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/providers/native/nativeTextRobustness.test.ts diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index ebb8580b8..8c237040a 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -44,8 +44,19 @@ interface ParsedLoadCommands { readonly flags: Set; } +/** + * 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)) diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index b07709024..6ec7cc0e3 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -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; diff --git a/tests/boundary/providers/native/nativeTextRobustness.test.ts b/tests/boundary/providers/native/nativeTextRobustness.test.ts new file mode 100644 index 000000000..68dd78e3f --- /dev/null +++ b/tests/boundary/providers/native/nativeTextRobustness.test.ts @@ -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"); +});