From 68c3c5b538b2f9d196eaaa18ef1ca1d817ae7622 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 17 Oct 2026 23:23:27 +0800 Subject: [PATCH] fix(native): preserve leading U+FEFF in Apple metadata names (#4730) * fix(native): preserve leading U+FEFF in Apple metadata names * test(native): run property metadata regressions on macOS --- .github/workflows/ci.yml | 1 + scripts/ci/scopes.mjs | 1 + src/native/AppleDispatchMetadata.ts | 3 +- .../nativeDispatchPropertyAttributes.test.ts | 29 +++++ .../appleDispatchMetadataStrings.test.ts | 110 ++++++++++++++++++ 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/appleDispatchMetadataStrings.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b034d8198f..c9a8e809d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -653,6 +653,7 @@ jobs: tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts tests/boundary/cli/nativePlistSelectionFailureCli.test.ts tests/boundary/cli/nativeDemangleCli.test.ts + tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts tests/boundary/mcp/nativeDemangleMcp.test.ts tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts tests/boundary/cli/nativeSignatureCli.test.ts diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7c46976677..d36a181f3f 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -77,6 +77,7 @@ export const SCOPES = { "tests/boundary/cli/nativePlistPrototypeKeyCli.test.ts", "tests/boundary/cli/nativePlistSelectionFailureCli.test.ts", "tests/boundary/cli/nativeDemangleCli.test.ts", + "tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts", "tests/boundary/mcp/nativeDemangleMcp.test.ts", "tests/boundary/providers/native/nativeSignatureTargetBinding.test.ts", "tests/boundary/cli/nativeSignatureCli.test.ts", diff --git a/src/native/AppleDispatchMetadata.ts b/src/native/AppleDispatchMetadata.ts index 641fee4003..ef1d345ec2 100644 --- a/src/native/AppleDispatchMetadata.ts +++ b/src/native/AppleDispatchMetadata.ts @@ -87,7 +87,8 @@ export const decodeAppleDispatchMetadata = ( if (end === bytes.length || end - start >= 4096) throw new RangeError("Unterminated or oversized metadata string"); offset(address, end - start + 1); - return new TextDecoder("utf-8", { fatal: true }).decode( + // A metadata C string owns every code point, including an initial U+FEFF. + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( bytes.subarray(start, end), ); }; diff --git a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts index d96f8deca6..a254028d66 100644 --- a/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts +++ b/tests/boundary/cli/nativeDispatchPropertyAttributes.test.ts @@ -10,6 +10,35 @@ import { cliTest } from "../../support/cli/cliFixture.js"; describe.skipIf(process.platform !== "darwin")( "native dispatch property attributes", () => { + cliTest( + "preserves a leading U+FEFF in a binary property name", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-objc-property-"); + const path = join(directory, "fixture.bin"); + await writeFile( + path, + machoWithPropertyAttributes("Tq,N", { firstName: "\ufefffirst" }), + ); + const output = await cli.run({ + arguments: ["inspect-native-dispatch-metadata", path, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(output.exitCode, `${output.stdout}\n${output.stderr}`).toBe(0); + expect(output.json).toMatchObject({ + normalized_result: { + result: { + objc_classes: [ + { + name: "Fixture", + properties: [{ name: "\ufefffirst" }, { name: "second" }], + }, + ], + }, + }, + }); + }, + ); + cliTest.for([ ["complete nonatomic", "Tq,N", "nonatomic", false, "complete"], ["complete default", 'T@"NSString",C', "atomic", false, "complete"], diff --git a/tests/boundary/filesystem/appleDispatchMetadataStrings.test.ts b/tests/boundary/filesystem/appleDispatchMetadataStrings.test.ts new file mode 100644 index 0000000000..da230322c0 --- /dev/null +++ b/tests/boundary/filesystem/appleDispatchMetadataStrings.test.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { inspectAppleDispatchMetadata } from "../../../src/native/AppleDispatchMetadata.js"; +import { withArtifactResourceScope } from "../../fixtures/artifactInventory.js"; +import { machoWithPropertyAttributes } from "../../fixtures/objcPropertyMacho.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + ["plain names", "Fixture", "first", "perform:"], + ["leading U+FEFF", "\ufeffFixture", "\ufefffirst", "\ufeffperform:"], + ["U+FEFF-only names", "\ufeff", "\ufeff", "\ufeff"], +] as const)( + "preserves %s in file-backed Apple metadata identities", + async (_, className, propertyName, selector) => { + const directory = await createTestTempDirectory("rea-dispatch-strings-"); + const path = join(directory, "fixture.bin"); + const bytes = machoWithPropertyAttributes("Tq,N", { + firstName: propertyName, + }); + bytes.write(`${className}\0`, 0x400); + const base = 0x100000000n; + bytes.writeBigUInt64LE(base + 0x700n, 0x320); + bytes.writeUInt32LE(24, 0x700); + bytes.writeUInt32LE(1, 0x704); + bytes.writeBigUInt64LE(base + 0x750n, 0x708); + bytes.writeBigUInt64LE(base + 0x780n, 0x710); + bytes.writeBigUInt64LE(base + 0x800n, 0x718); + bytes.write(`${selector}\0`, 0x750); + bytes.write("v@:\0", 0x780); + await writeFile(path, bytes); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const metadata = await withArtifactResourceScope((resources) => + resources.run(() => + inspectAppleDispatchMetadata(target.value, 100, resources), + ), + ); + expect(metadata.target_sha256).toBe( + createHash("sha256").update(bytes).digest("hex"), + ); + expect(metadata.result.objc_classes).toMatchObject([ + { + name: className, + methods: [{ selector }], + properties: [{ name: propertyName }, { name: "second" }], + location: { address: "0x100000200", file_offset: 0x200 }, + evidence: [ + { + artifact_path: target.value.path, + artifact_sha256: target.value.sha256, + location: { address: "0x100000200", file_offset: 0x200 }, + }, + ], + }, + ]); + expect(metadata.result.objc_dispatch_implementations).toMatchObject([ + { + class_name: className, + selector, + implementation_address: "0x100000800", + location: { address: "0x100000708", file_offset: 0x708 }, + }, + ]); + expect( + metadata.result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ status: "complete", reason: null }); + expect(await readFile(path)).toEqual(bytes); + }, +); + +it.each([ + ["malformed UTF-8", [0xef, 0xbb, 0]], + ["an empty name", [0]], +] as const)( + "keeps %s partial beside a valid Apple metadata identity", + async (_, name) => { + const directory = await createTestTempDirectory("rea-dispatch-strings-"); + const path = join(directory, "fixture.bin"); + const bytes = machoWithPropertyAttributes("Tq,N"); + // Invalid UTF-8 and a NUL-only name remain distinct from EF BB BF payload. + bytes.set(name, 0x600); + await writeFile(path, bytes); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const metadata = await withArtifactResourceScope((resources) => + resources.run(() => + inspectAppleDispatchMetadata(target.value, 100, resources), + ), + ); + expect(metadata.result.objc_classes).toMatchObject([ + { name: "Fixture", properties: [{ name: "second" }] }, + ]); + expect( + metadata.result.coverage.find( + ({ facet }) => facet === "objc_properties_categories", + ), + ).toMatchObject({ + status: "partial", + reason: expect.stringContaining("property_decode_failed"), + }); + expect(await readFile(path)).toEqual(bytes); + }, +);