diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 104aa7c760..d11a582b21 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -20,6 +20,7 @@ import { clientRequirementsFor, NO_CLIENT_FEATURES, } from "./CapabilityClientRequirements.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const statusSchema = z.object({ open: z.boolean(), @@ -210,7 +211,7 @@ export const buildCapabilityInventory = ( reason: availability.reason, remediation: availability.remediation, }; - }).sort((left, right) => left.name.localeCompare(right.name)); + }).sort((left, right) => compareUnicodeCodePoints(left.name, right.name)); }; const availabilityFor = (context: AvailabilityContext): Availability => { diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index a6a16f96c8..bad6ee52f5 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; import type { SetupClient } from "./SupportedClients.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; export type ClientConfigurationFormat = NonNullable; export type ClientServersKey = "mcp_servers" | "mcpServers" | "mcp" | "servers"; @@ -63,7 +64,7 @@ const normalizeConfigurationValue = (value: unknown): unknown => { return value; return Object.fromEntries( Object.entries(value) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareUnicodeCodePoints(left, right)) .map(([key, nested]) => [key, normalizeConfigurationValue(nested)]), ); }; diff --git a/src/application/ClientRegistrationEnvironment.ts b/src/application/ClientRegistrationEnvironment.ts index 0d537ba9b2..6048c2de15 100644 --- a/src/application/ClientRegistrationEnvironment.ts +++ b/src/application/ClientRegistrationEnvironment.ts @@ -8,6 +8,7 @@ import { type ClientConfigurationDocument, } from "./ClientConfigurationDocument.js"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Read explicit REA overrides without dropping peers when one value is invalid. */ export const clientRegistrationEnvironment = ( @@ -47,7 +48,7 @@ export const clientRegistrationEnvironment = ( return ok( Object.fromEntries( Object.entries(environment).sort(([left], [right]) => - left.localeCompare(right), + compareUnicodeCodePoints(left, right), ), ), ); diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 8c3e5035b5..b40079c451 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -25,6 +25,7 @@ import { supportedClients, } from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; interface ClientRegistrationStatusBase { readonly client: string; @@ -184,7 +185,7 @@ export const readClientRegistrationStatuses = async ( } } return statuses.sort((left, right) => - left.client.localeCompare(right.client), + compareUnicodeCodePoints(left.client, right.client), ); }; diff --git a/src/application/ReconstructionObligationCandidates.ts b/src/application/ReconstructionObligationCandidates.ts index 60eaaed135..a269829dc2 100644 --- a/src/application/ReconstructionObligationCandidates.ts +++ b/src/application/ReconstructionObligationCandidates.ts @@ -18,6 +18,7 @@ import { semanticObligationPolicy, type ReconstructionObligationCandidatePolicy, } from "./ReconstructionObligationPolicies.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type CandidateFields = Omit< ReconstructionObligation, @@ -54,7 +55,7 @@ export const deriveReconstructionObligationCandidates = ( addCandidate(candidates, reviewedCandidate(obligation, records)); return { candidates: [...candidates.values()].sort((left, right) => - left.obligation_id.localeCompare(right.obligation_id), + compareUnicodeCodePoints(left.obligation_id, right.obligation_id), ), limitations: [...limitations].sort(), }; @@ -430,7 +431,7 @@ const uniqueBy = ( key: (value: Value) => string, ): Value[] => [...new Map(values.map((value) => [key(value), value])).values()].sort( - (left, right) => key(left).localeCompare(key(right)), + (left, right) => compareUnicodeCodePoints(key(left), key(right)), ); const obligationId = (key: string): string => diff --git a/src/application/ReconstructionObligationLedgerDiagnostics.ts b/src/application/ReconstructionObligationLedgerDiagnostics.ts index d92fd21ef1..7d6d846371 100644 --- a/src/application/ReconstructionObligationLedgerDiagnostics.ts +++ b/src/application/ReconstructionObligationLedgerDiagnostics.ts @@ -3,6 +3,7 @@ import type { ReconstructionObligationManifest, } from "../domain/reconstructionObligationLedgerSchemas.js"; import type { Evidence } from "../domain/evidence.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type Diagnostic = ReconstructionObligation["diagnostics"][number]; type Binding = NonNullable; @@ -50,7 +51,8 @@ export const uniqueObligationDiagnostics = ( ]), ).values(), ].sort((left, right) => - `${left.code}:${left.detail}`.localeCompare( + compareUnicodeCodePoints( + `${left.code}:${left.detail}`, `${right.code}:${right.detail}`, ), ); diff --git a/src/application/ReconstructionObligationLedgerEvaluation.ts b/src/application/ReconstructionObligationLedgerEvaluation.ts index c83c79dc9b..fe76263449 100644 --- a/src/application/ReconstructionObligationLedgerEvaluation.ts +++ b/src/application/ReconstructionObligationLedgerEvaluation.ts @@ -36,6 +36,7 @@ import { obligationReports, summarizeObligations, } from "./ReconstructionObligationLedgerProjection.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type Diagnostic = ReconstructionObligation["diagnostics"][number]; type Binding = NonNullable; @@ -60,7 +61,7 @@ export const evaluateReconstructionObligationLedger = ({ }): ReconstructionObligationLedger => { const bundle = parseEvidenceBundle(bundleInput); const allCandidates = [...candidatesInput].sort((left, right) => - left.obligation_id.localeCompare(right.obligation_id), + compareUnicodeCodePoints(left.obligation_id, right.obligation_id), ); const orphanManifestIds = findOrphanManifestIds(allCandidates, manifest); const candidates = allCandidates; diff --git a/src/application/ReconstructionObligationOriginalCases.ts b/src/application/ReconstructionObligationOriginalCases.ts index 4d6cf3d383..46bb15be5b 100644 --- a/src/application/ReconstructionObligationOriginalCases.ts +++ b/src/application/ReconstructionObligationOriginalCases.ts @@ -2,6 +2,7 @@ import type { Evidence } from "../domain/evidence.js"; import type { ReconstructionObligation } from "../domain/reconstructionObligationLedgerSchemas.js"; import { evidenceSupportsOriginalAuthority } from "./ReconstructionObligationAuthority.js"; import type { ReconstructionObligationCandidate } from "./ReconstructionObligationCandidates.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; type Binding = NonNullable; type Diagnostic = ReconstructionObligation["diagnostics"][number]; @@ -21,7 +22,8 @@ export const originalCasesFor = ( ), ).values(), ].sort((left, right) => - `${left.kind}:${left.evidence_id}:${left.location}`.localeCompare( + compareUnicodeCodePoints( + `${left.kind}:${left.evidence_id}:${left.location}`, `${right.kind}:${right.evidence_id}:${right.location}`, ), ); diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index 2871bd04b2..83402d1a43 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -15,6 +15,7 @@ import type { } from "./SetupTypes.js"; import type { SetupClient } from "./SupportedClients.js"; import type { DoctorReport } from "./Doctor.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Read-only setup facts shared by client selection and preflight. */ export interface SetupDiscovery { @@ -308,7 +309,7 @@ const clientConfigurationDetail = ( installHopper: boolean, ): string => { const entries = Object.entries(environment) - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareUnicodeCodePoints(left, right)) .map(([name, value]) => `${name}=${value}`); const environmentDetail = entries.length === 0 diff --git a/src/application/SetupRegistrationEnvironment.ts b/src/application/SetupRegistrationEnvironment.ts index 7a46f56e1b..b9bf54f999 100644 --- a/src/application/SetupRegistrationEnvironment.ts +++ b/src/application/SetupRegistrationEnvironment.ts @@ -1,4 +1,5 @@ import type { DoctorProviderInspection } from "./Doctor.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Preserve adapter-published settings independently of runtime readiness. */ export const providerRegistrationEnvironment = ( @@ -9,5 +10,5 @@ export const providerRegistrationEnvironment = ( .flatMap(({ registrationEnvironment }) => Object.entries(registrationEnvironment), ) - .sort(([left], [right]) => left.localeCompare(right)), + .sort(([left], [right]) => compareUnicodeCodePoints(left, right)), ); diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 915458047c..c4b0bc6e93 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -6,6 +6,7 @@ import { HermesClientPathError, resolveHermesClientHome, } from "./HermesClientHome.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** One supported client configuration location. */ export interface SetupClient { @@ -642,6 +643,6 @@ export const clientSkillDirectories = ( }); } return [...destinations.values()].sort((left, right) => - left.directory.localeCompare(right.directory), + compareUnicodeCodePoints(left.directory, right.directory), ); }; diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts index 2702a07061..43e6bbb9cd 100644 --- a/src/application/binary/AnalysisProviderRegistry.ts +++ b/src/application/binary/AnalysisProviderRegistry.ts @@ -25,6 +25,7 @@ import { evaluateAnalysisProviderCandidate, type AnalysisProviderCandidateEvaluation, } from "./AnalysisProviderEvaluation.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; /** Deterministic discovery truth for one configured deep provider. */ export interface AnalysisProviderCandidateStatus { @@ -109,12 +110,14 @@ export class AnalysisProviderRegistry { } this.#providers = Object.freeze( [...providers].sort((left, right) => - left.identity().id.localeCompare(right.identity().id), + compareUnicodeCodePoints(left.identity().id, right.identity().id), ), ); this.#byId = byId; this.#declaredOperations = Object.freeze( - [...declaredOperations].sort((left, right) => left.localeCompare(right)), + [...declaredOperations].sort((left, right) => + compareUnicodeCodePoints(left, right), + ), ); } diff --git a/src/application/binary/BinarySessionStatus.ts b/src/application/binary/BinarySessionStatus.ts index 9048c6de62..8fbe4aa2b4 100644 --- a/src/application/binary/BinarySessionStatus.ts +++ b/src/application/binary/BinarySessionStatus.ts @@ -11,6 +11,7 @@ import type { SessionProviderRoute, SessionProviderRouter, } from "./SessionProviderRouter.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; interface BinarySessionStatusInput { readonly target: BinaryTarget | undefined; @@ -39,7 +40,9 @@ export const binarySessionStatus = ({ }: BinarySessionStatusInput): JsonValue => { const capabilities = [...route.capabilities.values()] .filter(({ operation }) => operation !== "inventory_artifact") - .sort((left, right) => left.operation.localeCompare(right.operation)) + .sort((left, right) => + compareUnicodeCodePoints(left.operation, right.operation), + ) .map((descriptor) => { const unavailable = runtimeUnavailability.get(descriptor.operation); return unavailable === undefined @@ -69,7 +72,9 @@ export const binarySessionStatus = ({ capabilities: candidate.capabilities .slice() .filter(({ operation }) => operation !== "inventory_artifact") - .sort((left, right) => left.operation.localeCompare(right.operation)) + .sort((left, right) => + compareUnicodeCodePoints(left.operation, right.operation), + ) .map(capabilityStatus), })); const common = { @@ -102,7 +107,9 @@ const requestActivityStatus = ( ) => { const providers = snapshots .slice() - .sort((left, right) => left.provider.id.localeCompare(right.provider.id)) + .sort((left, right) => + compareUnicodeCodePoints(left.provider.id, right.provider.id), + ) .map(({ provider, active, queuedRequests }) => ({ provider: providerSummary(provider), active: @@ -150,7 +157,7 @@ const processLineageStatus = ( snapshots: snapshots .slice() .sort((left, right) => - left.provider.id.localeCompare(right.provider.id), + compareUnicodeCodePoints(left.provider.id, right.provider.id), ) .map(({ provider, observation }) => ({ provider: providerSummary(provider), diff --git a/src/application/binary/CompositeProvider.ts b/src/application/binary/CompositeProvider.ts index 941cf93752..b0a7e7c13a 100644 --- a/src/application/binary/CompositeProvider.ts +++ b/src/application/binary/CompositeProvider.ts @@ -10,6 +10,7 @@ import type { ProviderIdentity, } from "../AnalysisProvider.js"; import { closeAnalysisClient } from "./AnalysisClientCleanup.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; /** Synthetic identity for a deterministic provider set. */ const compositeProviderIdentity = ( @@ -53,8 +54,8 @@ export class CompositeProvider implements AnalysisProvider { this.#capabilities = Object.freeze( capabilities.sort( (left, right) => - left.operation.localeCompare(right.operation) || - left.provider.id.localeCompare(right.provider.id), + compareUnicodeCodePoints(left.operation, right.operation) || + compareUnicodeCodePoints(left.provider.id, right.provider.id), ), ); } @@ -122,13 +123,13 @@ export class CompositeProvider implements AnalysisProvider { [...clients.values()] .flatMap((client) => client.runtimeLineageSnapshots?.() ?? []) .sort((left, right) => - left.provider.id.localeCompare(right.provider.id), + compareUnicodeCodePoints(left.provider.id, right.provider.id), ), requestActivitySnapshots: () => [...clients.values()] .flatMap((client) => client.requestActivitySnapshots?.() ?? []) .sort((left, right) => - left.provider.id.localeCompare(right.provider.id), + compareUnicodeCodePoints(left.provider.id, right.provider.id), ), operationHealthSnapshot: () => { for (const client of clients.values()) { diff --git a/src/application/investigation/EvidenceLedger.ts b/src/application/investigation/EvidenceLedger.ts index 1b88c5737d..fb27d40c8f 100644 --- a/src/application/investigation/EvidenceLedger.ts +++ b/src/application/investigation/EvidenceLedger.ts @@ -24,6 +24,7 @@ import { type UpdateUnknownInput, } from "../../domain/residualUnknown.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; type EvidenceLedgerFailure = EvidenceIntegrityError; type RecordResult = Result<"added" | "duplicate", EvidenceLedgerFailure>; @@ -321,7 +322,9 @@ export class EvidenceLedger { unknown.severity === filters.severity) && (filters.domain === undefined || unknown.domain === filters.domain), ) - .sort((left, right) => left.unknown_id.localeCompare(right.unknown_id)) + .sort((left, right) => + compareUnicodeCodePoints(left.unknown_id, right.unknown_id), + ) .map((unknown) => structuredClone(unknown)); } diff --git a/src/artifacts/AsarArtifactReader.test.ts b/src/artifacts/AsarArtifactReader.test.ts new file mode 100644 index 0000000000..54eb78cd4b --- /dev/null +++ b/src/artifacts/AsarArtifactReader.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { orderAsarListing } from "./AsarArtifactReader.js"; + +describe("orderAsarListing", () => { + it("orders host-spelled listings by their portable archive path", () => { + const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/"); + const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\"); + + // "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it. + expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]); + expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]); + expect(windows.map(({ listed }) => listed)).toEqual([ + "\\a", + "\\a\\b", + "\\a0", + ]); + }); + + it("keeps POSIX backslashes as literal filename characters", () => { + expect(orderAsarListing(["/a\\b"], "/")).toEqual([ + { listed: "/a\\b", path: "a\\b" }, + ]); + }); +}); diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index d08ebb0fe3..3e0f69a5b9 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -32,6 +32,7 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js"; import { admitAsarHeader } from "./AsarHeader.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** * Official Electron ASAR adapter with range-streamed member reads. @@ -64,7 +65,7 @@ export class AsarArtifactReader implements ArtifactReader { ) {} async *entries(signal?: AbortSignal): AsyncIterable { - let paths: string[]; + let paths: readonly AsarListedPath[]; try { await this.prepareContainer(undefined, signal); const snapshotPath = this.#snapshotPath(); @@ -82,9 +83,7 @@ export class AsarArtifactReader implements ArtifactReader { await headerHandle.close(); } abortIfNeeded(signal); - paths = listPackage(snapshotPath, { isPack: false }).sort((left, right) => - left.localeCompare(right, "en"), - ); + paths = orderAsarListing(listPackage(snapshotPath, { isPack: false })); const archiveMetadata = await lstat(snapshotPath); if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink()) throw new ArtifactReaderFailure( @@ -95,13 +94,8 @@ export class AsarArtifactReader implements ArtifactReader { } catch (cause: unknown) { throw asarFailure(this.path, "inventory", cause); } - for (const listed of paths) { + for (const { listed, path } of paths) { abortIfNeeded(signal); - // @electron/asar returns paths assembled with the host path module. - // Keep that spelling for its API calls, but expose archive paths with - // portable separators. On POSIX, backslashes can be literal filename - // characters and must remain untouched. - const path = toArtifactPath(listed); if (path.length === 0) continue; const providerPath = listed.startsWith(sep) ? listed.slice(sep.length) @@ -577,8 +571,30 @@ const waitForOperation = async ( }); }; -const toArtifactPath = (listed: string): string => { - const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed; +interface AsarListedPath { + readonly listed: string; + readonly path: string; +} + +/** + * Pair each host-spelled listing with its portable archive path, ordered by + * the portable path so Windows and POSIX hosts agree. + * + * @electron/asar returns paths assembled with the host path module. Keep that + * spelling for its API calls, but expose archive paths with portable + * separators. On POSIX, backslashes can be literal filename characters and + * must remain untouched. + */ +export const orderAsarListing = ( + listed: readonly string[], + separator: string = sep, +): AsarListedPath[] => + listed + .map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) })) + .sort((left, right) => compareUnicodeCodePoints(left.path, right.path)); + +const toArtifactPath = (listed: string, separator: string): string => { + const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed; return portable.replace(/^\/+|\/+$/gu, ""); }; diff --git a/src/browser/CdpBrowserProvider.ts b/src/browser/CdpBrowserProvider.ts index 7138710775..642373af56 100644 --- a/src/browser/CdpBrowserProvider.ts +++ b/src/browser/CdpBrowserProvider.ts @@ -68,6 +68,7 @@ import { captureCdpScreenshot } from "./CdpScreenshot.js"; import { comparePngScreenshots } from "./PngVisualDiff.js"; import { CDP_BROWSER_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const CLEANUP_DOMAINS = ["Network", "Debugger", "Runtime", "Page"] as const; /** Passive browser observation through a selected user-owned CDP endpoint. */ @@ -408,7 +409,9 @@ const selectTargets = ( attached: target.attached, }); } - allowed.sort((left, right) => left.target_id.localeCompare(right.target_id)); + allowed.sort((left, right) => + compareUnicodeCodePoints(left.target_id, right.target_id), + ); return { allowed, excluded: { diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 0b93059f2f..3c7fb98ed5 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -12,6 +12,7 @@ import { import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { createDocumentSvgAncestry, domSvgLink } from "./CdpCaptureDomSvg.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; export type CapturedResource = Omit< WebPageInspection["resources"][number], @@ -346,7 +347,7 @@ const accessibilityStates = ( if (state !== undefined) states.set(name, state); } return [...states] - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareUnicodeCodePoints(left, right)) .map(([name, state]) => ({ name, value: state })); }; diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index ca49edabd1..5aa9d95b87 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -24,6 +24,7 @@ import { import { captureElectronScripts } from "./CdpElectronScripts.js"; import { captureElectronWorkers } from "./CdpElectronWorkers.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; interface ElectronContext { readonly connection: CdpConnection; @@ -300,7 +301,7 @@ const captureResources = async ( pending.push(...recordsValue(tree.childFrames)); } return resources.sort((left, right) => - left.resource_key.localeCompare(right.resource_key), + compareUnicodeCodePoints(left.resource_key, right.resource_key), ); }; diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 7d42682cb3..8257d49748 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -30,6 +30,7 @@ import { inspectCdpElectronPage } from "./CdpElectronInspection.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Passive Electron provider for local file pages exposed by loopback CDP. */ export class CdpElectronProvider implements ElectronObservationPort { identity(): ProviderIdentity { @@ -73,7 +74,7 @@ export class CdpElectronProvider implements ElectronObservationPort { }); } allowed.sort((left, right) => - left.target_id.localeCompare(right.target_id), + compareUnicodeCodePoints(left.target_id, right.target_id), ); return ok( electronTargetListSchema.parse({ diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index 850cfd5f54..854b49413f 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -9,6 +9,7 @@ import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { requiredScriptSource } from "./CdpCaptureValues.js"; import type { ElectronScriptDraft } from "./CdpElectronScriptEvents.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; interface ElectronScriptCaptureInput { readonly connection: CdpConnection; @@ -65,7 +66,7 @@ export const captureElectronScripts = async ( return { total, items: items.sort((left, right) => - left.script_key.localeCompare(right.script_key), + compareUnicodeCodePoints(left.script_key, right.script_key), ), }; }; diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 080e1e46a2..b4e3d102f7 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -12,6 +12,7 @@ import { import type { CdpEndpointTarget } from "./CdpEndpoint.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Inventory local worker targets without attaching to them. */ export const captureElectronWorkers = async (input: { @@ -70,7 +71,7 @@ export const captureElectronWorkers = async (input: { }); } return workers.sort((left, right) => - left.target_id.localeCompare(right.target_id), + compareUnicodeCodePoints(left.target_id, right.target_id), ); }; diff --git a/src/browser/CdpPageCaptureScripts.ts b/src/browser/CdpPageCaptureScripts.ts index ffafa02e72..4108484340 100644 --- a/src/browser/CdpPageCaptureScripts.ts +++ b/src/browser/CdpPageCaptureScripts.ts @@ -13,6 +13,7 @@ import { } from "./CdpCaptureValues.js"; import type { CaptureContext } from "./CdpPageCapture.js"; import type { WebSourceMapRequest } from "./WebSourceMapFetcher.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; export interface CapturedScripts { readonly inventory: WebPageInspection["scripts"]; @@ -131,11 +132,15 @@ const keyAndBuildInventory = ( })); keyed.sort( (left, right) => - left.base.localeCompare(right.base) || - sourceDigest(left.item.source).localeCompare( + compareUnicodeCodePoints(left.base, right.base) || + compareUnicodeCodePoints( + sourceDigest(left.item.source), sourceDigest(right.item.source), ) || - (left.item.frame_id ?? "").localeCompare(right.item.frame_id ?? ""), + compareUnicodeCodePoints( + left.item.frame_id ?? "", + right.item.frame_id ?? "", + ), ); const totals = new Map(); for (const { base } of keyed) totals.set(base, (totals.get(base) ?? 0) + 1); diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index dd4b3fb6cf..b414bdd89c 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -7,7 +7,10 @@ import type { } from "../domain/webMcpDiscovery.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { digestCanonicalValue } from "../domain/canonicalDigest.js"; -import { compositeKey } from "../domain/unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../domain/unicodeCodePointOrder.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; @@ -521,7 +524,7 @@ const buildWebMcpResult = (options: WebMcpResultOptions): WebMcpDiscovery => { tools: { total: tools.size + completeness.droppedTotal, items: [...tools.values()].sort((left, right) => - left.tool_key.localeCompare(right.tool_key), + compareUnicodeCodePoints(left.tool_key, right.tool_key), ), }, completeness: completeness.snapshot(), diff --git a/src/catalogIdentity.ts b/src/catalogIdentity.ts index a6141ee18c..408ec68e09 100644 --- a/src/catalogIdentity.ts +++ b/src/catalogIdentity.ts @@ -6,9 +6,10 @@ import { z } from "zod"; import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js"; import { TOOL_CONTRACTS } from "./contracts/toolContracts.js"; import { CLI_COMMAND_NAMES } from "./cliCommandNames.js"; +import { compareUnicodeCodePoints } from "./domain/unicodeCodePointOrder.js"; const sortedToolContracts = [...TOOL_CONTRACTS].sort((left, right) => - left.name.localeCompare(right.name), + compareUnicodeCodePoints(left.name, right.name), ); const createToolCatalog = () => @@ -39,7 +40,7 @@ const promptCatalog = PROMPT_CONTRACTS.map((contract) => ({ description: contract.description, arguments: contract.arguments, steps: contract.steps, -})).sort((left, right) => left.name.localeCompare(right.name)); +})).sort((left, right) => compareUnicodeCodePoints(left.name, right.name)); const digest = (value: unknown): string => { const encoded = canonicalize(value); diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 4a5aa3d842..10201905ea 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -19,6 +19,7 @@ import { import type { FirmwareRequest } from "../domain/firmware/firmwareAnalysis.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { FIRMWARE_LIMITS } from "./FirmwareRelease.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** One regular file or unpublished filesystem entry in a private extraction. */ export interface FirmwareEntry { @@ -261,7 +262,9 @@ export const inventoryFirmwareOutput = async ( }); } } - return entries.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return entries.sort((a, b) => + compareUnicodeCodePoints(a.relativePath, b.relativePath), + ); }; /** Bind producer paths to the selected input or private extraction tree. */ diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index 85e3a19af9..1315a61c6c 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -6,6 +6,7 @@ import { readWindowsPrivateRuntimeFile } from "../windows/WindowsPrivateRuntime. import { err, ok, type Result } from "../domain/result.js"; import { GhidraSessionError } from "./GhidraSessionError.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; /** Local bridge transports used by the current Ghidra session. */ export type GhidraTransportKind = "unix-socket" | "authenticated-loopback-tcp"; @@ -147,7 +148,7 @@ const hasExactKeys = ( expected: readonly string[], ): value is Record => { const keys = Object.keys(value).sort((left, right) => - left.localeCompare(right), + compareUnicodeCodePoints(left, right), ); return ( keys.length === expected.length && diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index d8fee75721..b1ea22624d 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -13,6 +13,7 @@ import { parseDyldSymbols } from "./parsers/dyldInfo.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; import { withoutEchoedPathHeader } from "./parsers/echoedPath.js"; import { parseOtoolLoadCommands } from "./parsers/otool.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; interface NativeMachoObservation { readonly result: JsonValue; @@ -270,7 +271,7 @@ const uniqueSymbols = ( for (const item of items) if (!unique.has(item.name)) unique.set(item.name, item); return [...unique.values()].sort((left, right) => - left.name.localeCompare(right.name), + compareUnicodeCodePoints(left.name, right.name), ); }; diff --git a/src/process/ProcessOwnership.ts b/src/process/ProcessOwnership.ts index f9fd5d99b2..af85d23b47 100644 --- a/src/process/ProcessOwnership.ts +++ b/src/process/ProcessOwnership.ts @@ -8,6 +8,7 @@ import { import { launcherIdentityFailure } from "./ProcessOwnershipIdentity.js"; import { descendantsOf, liveProcesses } from "./ProcessOwnershipProcessTree.js"; import { readProcessRunTokens } from "./ProcessRunTokenObservations.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; const execFileAsync = promisify(execFile); @@ -613,7 +614,7 @@ const cleanupValidationFailure = ( counts.set(category, (counts.get(category) ?? 0) + 1); } const breakdown = [...counts] - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => compareUnicodeCodePoints(left, right)) .map(([category, count]) => `${category}=${String(count)}`) .join(", "); const candidates = unreadable @@ -621,7 +622,7 @@ const cleanupValidationFailure = ( ({ pid, diagnostic }) => `${String(pid)}=${sanitizedTokenReadFailure(diagnostic)}`, ) - .sort((left, right) => left.localeCompare(right)); + .sort((left, right) => compareUnicodeCodePoints(left, right)); return { cleaned: false, reason: `process ownership token could not be read for ${String(unreadable.length)} live process(es): ${breakdown}; live candidates ${candidates.join(", ")}`, diff --git a/src/server/registerInvestigationTools/helpers.ts b/src/server/registerInvestigationTools/helpers.ts index 1d342c0b56..557f96225f 100644 --- a/src/server/registerInvestigationTools/helpers.ts +++ b/src/server/registerInvestigationTools/helpers.ts @@ -5,6 +5,7 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; import { recordDerivedEvidence } from "../recordDerivedEvidence.js"; import type { WorkflowUnknownInput } from "./types.js"; +import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; export const isIncomplete = (status: string): boolean => status === "unknown" || status === "truncated"; @@ -21,7 +22,9 @@ export const functionEvidenceIds = (records: readonly Evidence[]): string[] => uniqueIds(records.map(({ evidence_id }) => evidence_id)); const uniqueIds = (ids: readonly string[]): string[] => - [...new Set(ids)].sort((left, right) => left.localeCompare(right)); + [...new Set(ids)].sort((left, right) => + compareUnicodeCodePoints(left, right), + ); export const evidenceClosure = ( session: BinarySessionPort, diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index f3a1816242..c63b81893a 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -1,8 +1,11 @@ -import { writeFile } from "node:fs/promises"; +import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; -import { readFirmwareReport } from "../../../src/firmware/FirmwareFiles.js"; +import { + inventoryFirmwareOutput, + readFirmwareReport, +} from "../../../src/firmware/FirmwareFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it.each([ @@ -41,3 +44,24 @@ it("preserves valid Unicode and a literal replacement character in producer JSON report, ); }); + +it("orders extracted firmware paths by code point independently of host locale", async () => { + const root = await createTestTempDirectory("rea-firmware-inventory-"); + await mkdir(join(root, "Lib")); + for (const path of ["a.bin", "z.bin", "\u00e4.bin", "B.bin", "Lib/x.bin"]) + await writeFile(join(root, path), ""); + const entries = await inventoryFirmwareOutput( + root, + 1024, + 16, + "inspect_firmware_regions", + ); + // Locale collation would interleave case and place "รค" by language rules. + expect(entries.map(({ relativePath }) => relativePath)).toEqual([ + "B.bin", + "Lib/x.bin", + "a.bin", + "z.bin", + "\u00e4.bin", + ]); +});