From ec3db374574060fcc4fa6b215fceaea1bd4ca3f3 Mon Sep 17 00:00:00 2001 From: Yijie Lu <115929844+1NormalGuy@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:22:34 +0800 Subject: [PATCH] fix(native): preserve literal line breaks in signing identifiers (#2107) * fix(native): preserve literal line breaks in signing identifiers * fix(native): infer codesign framing without a matching path --- src/native/parsers/codesign.ts | 38 +++++--- tests/boundary/cli/nativeSignatureCli.test.ts | 10 +- .../providers/native/nativeProvider.test.ts | 95 ++++++++++++++----- 3 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/native/parsers/codesign.ts b/src/native/parsers/codesign.ts index 3a39a389ea..74a472f33a 100644 --- a/src/native/parsers/codesign.ts +++ b/src/native/parsers/codesign.ts @@ -16,14 +16,11 @@ export const parseCodeSignature = ( unsigned: boolean, executablePaths: readonly string[] = [], ): Omit => { - const { identifier, fields } = splitEchoedValues( - output.replaceAll("\r\n", "\n"), - executablePaths, - ); + const { identifier, fields } = splitEchoedValues(output, executablePaths); const values = new Map(); const authorities: string[] = []; const cdhashes: string[] = []; - for (const line of fields.split("\n")) { + for (const line of fields.split(/\r?\n/u)) { if (line.startsWith("CodeDirectory ")) { values.set("CodeDirectory", line.slice("CodeDirectory ".length)); continue; @@ -65,12 +62,20 @@ const splitEchoedValues = ( text: string, executablePaths: readonly string[], ): { readonly identifier: string | undefined; readonly fields: string } => { - const executable = executablePaths - .map((path) => `Executable=${path}\n`) - .find((line) => text.startsWith(line)); let rest = text; - if (executable !== undefined) rest = text.slice(executable.length); - else if (text.startsWith("Executable=")) { + // Frame the exact source path before interpreting record separators. A CRLF + // inside that path or the signing identifier is payload, not a line ending. + let newline: string | undefined; + for (const path of executablePaths) { + const prefix = `Executable=${path}`; + if (!text.startsWith(prefix)) continue; + const separator = /^\r?\n/u.exec(text.slice(prefix.length))?.[0]; + if (separator === undefined) continue; + newline = separator; + rest = text.slice(prefix.length + separator.length); + break; + } + if (rest === text && text.startsWith("Executable=")) { const identifierLine = text.indexOf("\nIdentifier="); rest = identifierLine < 0 ? text : text.slice(identifierLine + 1); } @@ -79,8 +84,17 @@ const splitEchoedValues = ( // Later fields come from the signature itself, so the last `Format=` line // is the one codesign printed after the identifier. const format = rest.lastIndexOf("\nFormat="); - const lineEnd = rest.indexOf("\n"); - const end = format >= 0 ? format : lineEnd >= 0 ? lineEnd : rest.length; + // Without an exact path, use the producer-owned Format record's terminator. + // Looking inside Identifier instead would confuse literal CRLF payload with + // record framing, or strip a literal trailing CR from an LF-delimited value. + if (newline === undefined && format >= 0) + newline = /\r?\n/u.exec(rest.slice(format + 1))?.[0]; + const boundary = format >= 0 ? format : rest.indexOf("\n"); + const crlf = newline === undefined || newline === "\r\n"; + const end = + boundary < 0 + ? rest.length + : boundary - (crlf && rest[boundary - 1] === "\r" ? 1 : 0); return { identifier: rest.slice("Identifier=".length, end), fields: rest.slice(end), diff --git a/tests/boundary/cli/nativeSignatureCli.test.ts b/tests/boundary/cli/nativeSignatureCli.test.ts index 9302d9c241..e30581db4c 100644 --- a/tests/boundary/cli/nativeSignatureCli.test.ts +++ b/tests/boundary/cli/nativeSignatureCli.test.ts @@ -21,14 +21,14 @@ const adHocProgram = async (directory: string, name: string) => { describe.skipIf(process.platform !== "darwin")( "native signature CLI with echoed path text", () => { - cliTest( - "does not read signature fields from the file name codesign echoes", - async ({ cli }) => { + cliTest.for(["\n", "\r\n"])( + "does not read signature fields from the file name codesign echoes (%j)", + async (newline, { cli }) => { const directory = await createTestTempDirectory("rea-signature-"); const forged = "Developer ID Application: Example (ABCDE12345)"; const program = await adHocProgram( directory, - `tool\nAuthority=${forged}\nTeamIdentifier=ABCDE12345`, + `tool${newline}Authority=${forged}${newline}Identifier=forged${newline}TeamIdentifier=ABCDE12345`, ); const unsignedName = await adHocProgram( directory, @@ -75,7 +75,7 @@ describe.skipIf(process.platform !== "darwin")( normalized_result: { identifier: expect.stringMatching( new RegExp( - `^tool\\nAuthority=${forged.replace(/[()]/gu, "\\$&")}\\nTeamIdentifier=ABCDE12345-`, + `^tool${newline}Authority=${forged.replace(/[()]/gu, "\\$&")}${newline}Identifier=forged${newline}TeamIdentifier=ABCDE12345-`, "u", ), ), diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 3dd2a9b2c8..0d1860b160 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -61,7 +61,13 @@ afterEach(async () => { }); describe("native macOS provider discovery", () => { - it.each(["ordinary.fixture", " spaced.fixture ", " leading", "trailing "])( + it.each([ + "ordinary.fixture", + " spaced.fixture ", + " leading", + "trailing ", + "literal\r\nidentifier\r", + ])( "preserves the exact signing identifier %j reported by codesign", (identifier) => { const parsed = parseCodeSignature( @@ -74,31 +80,68 @@ describe("native macOS provider discovery", () => { }, ); - it("separates echoed path and identifier text from signature fields", () => { - const path = "/apps/x\nAuthority=Forged\nIdentifier=forged"; - const identifier = "x\nFormat=forged\nAuthority=Forged-5555"; - const parsed = parseCodeSignature( - [ - `Executable=${path}`, - `Identifier=${identifier}`, - "Format=Mach-O thin (arm64)", - "CodeDirectory v=20400 size=342 flags=0x2(adhoc) hashes=2+2", - "CDHash=6aee90e6", - "Signature=adhoc", - "TeamIdentifier=not set", - "", - ].join("\r\n"), - false, - ["/elsewhere", path], - ); - expect(parsed).toMatchObject({ - identifier, - format: "Mach-O thin (arm64)", - authorities: [], - cdhashes: ["6aee90e6"], - team_identifier: null, - }); - }); + it.each(["\n", "\r\n"])( + "separates echoed path and identifier text from signature fields (%j)", + (newline) => { + const path = "/apps/x\r\nAuthority=Forged\nIdentifier=forged"; + const identifier = "x\r\nFormat=forged\nAuthority=Forged-5555\r"; + const parsed = parseCodeSignature( + [ + `Executable=${path}`, + `Identifier=${identifier}`, + "Format=Mach-O thin (arm64)", + "CodeDirectory v=20400 size=342 flags=0x2(adhoc) hashes=2+2", + "CDHash=6aee90e6", + "Signature=adhoc", + "TeamIdentifier=not set", + "", + ].join(newline), + false, + ["/elsewhere", path], + ); + expect(parsed).toMatchObject({ + identifier, + format: "Mach-O thin (arm64)", + authorities: [], + cdhashes: ["6aee90e6"], + team_identifier: null, + }); + }, + ); + + it.each([ + { executable: "", paths: [], newline: "\r\n", identifier: "fixture" }, + { + executable: "Executable=/apps/fixture\r\n", + paths: [], + newline: "\r\n", + identifier: "fixture\nFormat=payload\r", + }, + { + executable: "Executable=/apps/fixture\n", + paths: ["/elsewhere"], + newline: "\n", + identifier: "fixture\r\nFormat=payload\r", + }, + { + executable: "Executable=/apps/fixture\r\n", + paths: ["/elsewhere"], + newline: "\r\n", + identifier: "fixture\r", + }, + ])( + "frames the identifier without a matching executable path: %j", + ({ executable, paths, newline, identifier }) => { + const parsed = parseCodeSignature( + `${executable}Identifier=${identifier}${newline}Format=Mach-O thin (arm64)${newline}TeamIdentifier=not set${newline}`, + false, + paths, + ); + expect(parsed.identifier).toBe(identifier); + expect(parsed.format).toBe("Mach-O thin (arm64)"); + expect(parsed.team_identifier).toBeNull(); + }, + ); it.each(["timeout", "output-limit"] as const)( "preserves %s resolution failures with their captured output and cleanup status",