From 7072f87baf096eb944f15bc52e3925653d459cba Mon Sep 17 00:00:00 2001 From: xin zhuang <65798732+1052326311@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:38:32 +0800 Subject: [PATCH] fix(artifacts): preserve Mach-O load-command string identity (#2110) * fix(artifacts): preserve Mach-O load-command string identity * fix(process): report no remaining time after a provider deadline fires Node timers can fire while performance.now() is still a fraction of a millisecond short of the monotonic deadline. ProviderStartupDeadline then reported interruption "timeout" and an aborted signal while remainingMs() returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0 (Hopper and Ghidra startup) could run past an elapsed deadline, and the real-timer test "aborts a bounded wait when its real monotonic deadline expires" failed intermittently in CI (expected 1 to be 0). Once the deadline's own timer has classified a timeout, report zero remaining time. A deterministic regression test fires the timer with the monotonic clock 0.4 ms short of the deadline. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit dfdaa0ca74cc84a45dcb186422a0f51aa61bc427) * fix(ci): isolate Linux Hopper process fixture inventory * fix(comparison): recheck historical calls-only changes (#2223) * fix(comparison): preserve incomplete function call evidence Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty. Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls. * fix(comparison): recheck historical calls-only changes --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> * style: format identity comparison and IDA runner --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Lucien <164857476+dakjdakd@users.noreply.github.com> --- .../apple/MachoLoadCommandReader.test.ts | 21 ++++++++ src/artifacts/apple/MachoLoadCommandReader.ts | 3 +- tests/boundary/cli/dylibResolutionCli.test.ts | 51 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) 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();