From e4ac7bbfc2f8c631ba1c60f507d85253e122d4c8 Mon Sep 17 00:00:00 2001 From: Itoje Dollar <163161978+dollarsmoney@users.noreply.github.com> Date: Sun, 11 Oct 2026 10:10:01 -0700 Subject: [PATCH] fix: order remaining outputs independently of host locale (#1867) * fix: order remaining outputs independently of host locale String.prototype.localeCompare without a pinned collation follows the host's ICU default locale, so the same input could be ordered differently on different machines. 614fb768 moved canonical domain projections to compareUnicodeCodePoints; provider, application, catalog, and setup orderings still used locale collation. Order every remaining source site by Unicode code point, including Mach-O symbols, firmware extraction paths, ASAR entries (previously pinned to "en" collation), evidence ID sets, browser and Electron target, script, and resource inventories, reconstruction obligations, capability and provider listings, and setup environment details. The current tool and prompt catalog names order identically under both comparators, so the catalog identity digest is unchanged. * fix(artifacts): order ASAR entries by their portable path @electron/asar lists host-spelled paths, so code-point order over the raw spelling still differed by host: "a\b" sorts after "a0" on Windows while "a/b" sorts before it on POSIX. Pair each listing with its portable archive path once, order by that path, and keep the host spelling for @electron/asar calls and adapter keys. --- src/application/CapabilityInventory.ts | 3 +- .../ClientConfigurationDocument.ts | 3 +- .../ClientRegistrationEnvironment.ts | 3 +- src/application/ClientRegistrationStatus.ts | 3 +- .../ReconstructionObligationCandidates.ts | 5 ++- ...constructionObligationLedgerDiagnostics.ts | 4 +- ...econstructionObligationLedgerEvaluation.ts | 3 +- .../ReconstructionObligationOriginalCases.ts | 4 +- src/application/SetupPlan.ts | 3 +- .../SetupRegistrationEnvironment.ts | 3 +- src/application/SupportedClients.ts | 3 +- .../binary/AnalysisProviderRegistry.ts | 7 +++- src/application/binary/BinarySessionStatus.ts | 15 +++++-- src/application/binary/CompositeProvider.ts | 9 +++-- .../investigation/EvidenceLedger.ts | 5 ++- src/artifacts/AsarArtifactReader.test.ts | 25 ++++++++++++ src/artifacts/AsarArtifactReader.ts | 40 +++++++++++++------ src/browser/CdpBrowserProvider.ts | 5 ++- src/browser/CdpCaptureDocuments.ts | 3 +- src/browser/CdpElectronInspection.ts | 3 +- src/browser/CdpElectronProvider.ts | 3 +- src/browser/CdpElectronScripts.ts | 3 +- src/browser/CdpElectronWorkers.ts | 3 +- src/browser/CdpPageCaptureScripts.ts | 11 +++-- src/browser/CdpWebMcpDiscovery.ts | 7 +++- src/catalogIdentity.ts | 5 ++- src/firmware/FirmwareFiles.ts | 5 ++- src/ghidra/GhidraTransport.ts | 3 +- src/native/NativeMachoInspection.ts | 3 +- src/process/ProcessOwnership.ts | 5 ++- .../registerInvestigationTools/helpers.ts | 5 ++- .../firmware/firmwareReportFiles.test.ts | 28 ++++++++++++- 32 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 src/artifacts/AsarArtifactReader.test.ts 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", + ]); +});