diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts index 97bceb672a..3dc6334d7a 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.test.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts @@ -229,6 +229,27 @@ it("selects matching arm64e subtype slices when tracing a mixed FAT image", asyn }); describe("Mach-O load command reader: decodes thin-image load commands", () => { + it("preserves leading U+FEFF in load-command strings", async () => { + const image = machoImage({ + commands: [ + dylibCommand(LC.ID_DYLIB, "\uFEFF@rpath/libself.dylib"), + dylibCommand(LC.LOAD_DYLIB, "\uFEFF@rpath/libother.dylib"), + rpathCommand("\uFEFF@loader_path/Frameworks"), + dyldEnvironmentCommand("\uFEFFDYLD_LIBRARY_PATH=/example"), + ], + }); + expect(await read(image)).toMatchObject({ + status: "parsed", + slices: [ + { + install_name: "\uFEFF@rpath/libself.dylib", + dependencies: [{ install_name: "\uFEFF@rpath/libother.dylib" }], + rpaths: ["\uFEFF@loader_path/Frameworks"], + dyld_environment: ["\uFEFFDYLD_LIBRARY_PATH=/example"], + }, + ], + }); + }); it("decodes dylib loading commands of a thin image", async () => { const image = machoImage({ fileType: FILE_TYPE.dylib, diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 27f7ec283b..55b134cf5e 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -456,7 +456,8 @@ const commandString = ( `load command ${index} has an unterminated string`, ); try { - return new TextDecoder("utf-8", { fatal: true }).decode( + // lc_str contains name/path data, so a leading U+FEFF is part of its identity. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( body.subarray(offset, end), ); } catch { diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts index 21ea99a9c6..6d72e4de61 100644 --- a/tests/boundary/cli/dylibResolutionCli.test.ts +++ b/tests/boundary/cli/dylibResolutionCli.test.ts @@ -106,6 +106,57 @@ const withBrokenHelper = async (app: string): Promise => { await writeFiles(app, { "Contents/Helpers/broken": broken }); }; +cliTest( + "does not turn U+FEFF-prefixed strings into dyld path tokens", + async ({ cli }) => { + const app = await fixtureApp(); + await writeFiles(app, { + "Contents/MacOS/App": machoImage({ + commands: [ + buildVersionCommand(1), + rpathCommand("@executable_path/../Frameworks"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"), + dylibCommand(LC.LOAD_DYLIB, "\uFEFF@rpath/libcore.dylib"), + ], + }), + "Contents/Helpers/tool": machoImage({ + commands: [ + buildVersionCommand(1), + rpathCommand("\uFEFF@loader_path/../Frameworks"), + dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"), + ], + }), + }); + const output = await cli.run({ + arguments: ["trace-dylib-resolution", app, "--json"], + environment: ENVIRONMENT, + }); + expect(output.exitCode, output.stderr).toBe(0); + const trace = dylibResolutionResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + const ordinary = trace.edges.find( + ({ loader, install_name }) => + loader === "Contents/MacOS/App" && + install_name === "@rpath/libcore.dylib", + ); + expect(ordinary?.resolution).toMatchObject({ + status: "resolved", + image: "Contents/Frameworks/libcore.dylib", + }); + const marked = trace.edges.find( + ({ install_name }) => install_name === "\uFEFF@rpath/libcore.dylib", + ); + expect(marked).toBeDefined(); + expect(marked?.resolution.image).toBeNull(); + const helper = trace.edges.find( + ({ loader }) => loader === "Contents/Helpers/tool", + ); + expect(helper).toBeDefined(); + expect(helper?.resolution.image).toBeNull(); + }, +); + describe("trace-dylib-resolution CLI", () => { cliTest("traces every executable root of an app bundle", async ({ cli }) => { const app = await fixtureApp();