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 104aa7c76..d11a582b2 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 a6a16f96c..bad6ee52f 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 0d537ba9b..6048c2de1 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 8c3e5035b..b40079c45 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 60eaaed13..a269829dc 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 d92fd21ef..7d6d84637 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 c83c79dc9..fe7626344 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 4d6cf3d38..46bb15be5 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 2871bd04b..83402d1a4 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 7a46f56e1..b9bf54f99 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 915458047..c4b0bc6e9 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 2702a0706..43e6bbb9c 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 9048c6de6..8fbe4aa2b 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 941cf9375..b0a7e7c13 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 1b88c5737..fb27d40c8 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 000000000..54eb78cd4 --- /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 d08ebb0fe..3e0f69a5b 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 713871077..642373af5 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 0b93059f2..3c7fb98ed 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 ca49edabd..5aa9d95b8 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 7d42682cb..8257d4974 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 850cfd5f5..854b49413 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 080e1e46a..b4e3d102f 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 ffafa02e7..410848434 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 dd4b3fb6c..b414bdd89 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 a6141ee18..408ec68e0 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 4a5aa3d84..10201905e 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 85e3a19af..1315a61c6 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 d8fee7572..b1ea22624 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 f9fd5d99b..af85d23b4 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 1d342c0b5..557f96225 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 f3a181624..c63b81893 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", + ]); +});