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
This commit is contained in:
Wade authored and GitHub committed 2026-10-14 16:35:00 +08:00
1 parent 535c8ef303
commit 57290a01ac
6 files changed
+35 -20

No files matched your search

+3 -3
View File
@@ -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 = (
+26
View File
@@ -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`),
);
},
);
});
+2 -5
View File
@@ -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");
+1 -3
View File
@@ -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;
+3 -3
View File
@@ -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 = (
@@ -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);