From 57290a01ac9e89b1e3f395e0bc2611cc47a6762b Mon Sep 17 00:00:00 2001 From: Wade <160517347+Sriram-PR@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:35:00 +1100 Subject: [PATCH] fix(apple): order projection paths by Unicode code point (#3294) The Apple projection sorted application roots, bundle paths and symlinks with relational string operators, which compare UTF-16 code units, so a name with U+10000 sorted before one with U+FFFD. Use the shared compareUnicodeCodePoints instead and remove compareProjectionStrings, whose documented code point contract it did not meet. Fixes #3177 --- src/domain/android/androidApplication.ts | 6 ++--- src/domain/apple/appleApplication.test.ts | 26 +++++++++++++++++++ src/domain/apple/appleApplication.ts | 7 ++--- src/domain/apple/appleBundleAnatomy.ts | 4 +-- src/domain/harmony/harmonyApplication.ts | 6 ++--- .../mobileApplicationGraphProjection.ts | 6 ----- 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 6425c5bacc..17014f1b51 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -10,7 +10,6 @@ import { nativeRuntimeConvention } from "../nativeConvention.js"; import { applicationInventoryProjectionInputSchema, bridgeCandidateCoverageSchema, - compareProjectionStrings, projectCartesianCandidates, projectedBridgeCandidateSchema, projectedComponentSchema, @@ -21,6 +20,7 @@ import { unavailableComponentSchema, unavailableComponents, } from "../mobileApplicationGraphProjection.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; const evidenceIdSchema = projectionEvidenceIdSchema; const componentSchema = projectedComponentSchema; @@ -133,7 +133,7 @@ export const projectAndroidApplication = ( root_format: "apk" as const, source_evidence_ids: evidence .map(({ evidence_id: id }) => id) - .sort(compareProjectionStrings), + .sort(compareUnicodeCodePoints), components, unavailable_components: unavailable, runtime_families: runtimeFamilies(all), @@ -184,7 +184,7 @@ const runtimeFamilies = (all: readonly Component[]) => { const convention = nativeRuntimeConvention(path); if (convention !== null) families.add(convention); } - return [...families].sort(compareProjectionStrings); + return [...families].sort(compareUnicodeCodePoints); }; const bridgeCandidates = ( diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 3f8906e2b8..00b365ef39 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -677,4 +677,30 @@ describe("Apple path conventions", () => { ); expect(result.runtime_families).not.toContain("swift-objective-c"); }); + + it.each([ + // U+10000 sorts before U+FFFD by UTF-16 code unit, after it by code point. + ["an astral and a BMP name", "\uFFFD", "\u{10000}"], + ["two BMP names", "\uE000", "\uFFFD"], + ])( + "orders roots, bundles and symlinks by code point for %s", + (_label, low, high) => { + const result = project( + inventoryEvidence( + "zip", + "Fixture.zip", + [high, low].flatMap((name) => [ + { path: `A${name}.app/Contents/Info.plist` }, + { path: `A${name}.app/Contents/Resources/link`, kind: "symlink" }, + ]), + ), + ); + const roots = [`A${low}.app`, `A${high}.app`]; + expect(result.application_roots).toEqual(roots); + expect(result.bundles.map(({ path }) => path)).toEqual(roots); + expect(result.symlinks).toEqual( + roots.map((root) => `${root}/Contents/Resources/link`), + ); + }, + ); }); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 6dac13f826..587eb5a027 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -28,10 +28,8 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; -import { - compareProjectionStrings, - projectionDigest, -} from "../mobileApplicationGraphProjection.js"; +import { projectionDigest } from "../mobileApplicationGraphProjection.js"; +import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const pathSchema = z.string().min(1); @@ -450,6 +448,5 @@ const projectionLimitations = (facts: { "A bridge basis is inferred from the native path and is repeated for every managed component.", ]; -const compare = compareProjectionStrings; const digest = (value: unknown): string => projectionDigest(value, "Apple application projection"); diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index 05eb9d4546..feaadc4ea5 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { digestSchema, prefixedDigestSchema } from "../digests.js"; +import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; const pathSchema = z.string().min(1); @@ -376,6 +377,3 @@ export const platformsOf = (bundles: readonly Bundle[]): ("ios" | "macos")[] => }), ), ].sort(compare); - -const compare = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; diff --git a/src/domain/harmony/harmonyApplication.ts b/src/domain/harmony/harmonyApplication.ts index 219aed7145..88fb19e0e7 100644 --- a/src/domain/harmony/harmonyApplication.ts +++ b/src/domain/harmony/harmonyApplication.ts @@ -10,7 +10,6 @@ import { nativeRuntimeConvention } from "../nativeConvention.js"; import { applicationInventoryProjectionInputSchema, bridgeCandidateCoverageSchema, - compareProjectionStrings, projectCartesianCandidates, projectedBridgeCandidateSchema, projectedComponentSchema, @@ -21,6 +20,7 @@ import { unavailableComponentSchema, unavailableComponents, } from "../mobileApplicationGraphProjection.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; const evidenceIdSchema = projectionEvidenceIdSchema; const componentSchema = projectedComponentSchema; @@ -124,7 +124,7 @@ export const projectHarmonyApplication = ( root_format: rootFormat, source_evidence_ids: evidence .map(({ evidence_id: id }) => id) - .sort(compareProjectionStrings), + .sort(compareUnicodeCodePoints), packaging_model: packagingModel(classified.manifests), components: classified, unavailable_components: unavailable, @@ -206,7 +206,7 @@ const runtimeFamilies = (all: readonly Component[]) => { const convention = nativeRuntimeConvention(path); if (convention !== null) families.add(convention); } - return [...families].sort(compareProjectionStrings); + return [...families].sort(compareUnicodeCodePoints); }; const bridgeCandidates = ( diff --git a/src/domain/mobileApplicationGraphProjection.ts b/src/domain/mobileApplicationGraphProjection.ts index ba581a9b17..b143e44034 100644 --- a/src/domain/mobileApplicationGraphProjection.ts +++ b/src/domain/mobileApplicationGraphProjection.ts @@ -119,12 +119,6 @@ export const unavailableComponents = ( export { bridgeCandidateCoverageSchema, projectCartesianCandidates }; -/** Unicode code point ordering so projection arrays sort deterministically. */ -export const compareProjectionStrings = ( - left: string, - right: string, -): number => (left < right ? -1 : left > right ? 1 : 0); - /** Digest a canonical JSON projection payload for its stable identifier. */ export const projectionDigest = (value: unknown, label: string): string => { const encoded = canonicalize(value);