From 83cd1e04c93236b0338a98dfc7dedd94fadc81de Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:10:18 +0800 Subject: [PATCH] fix(javascript)!: report ambiguous artifact paths (#7864) * fix(javascript): report ambiguous artifact paths * fix(javascript): retain unavailable colliding sources --- .../javascript/JavaScriptArtifactFiles.ts | 88 +++++++-- .../javascriptAsarCollisionCli.test.ts | 81 ++++++++ .../javascriptArtifactContentIdentity.test.ts | 186 ++++++++++++++++++ 3 files changed, 334 insertions(+), 21 deletions(-) create mode 100644 tests/acceptance/applications/javascriptAsarCollisionCli.test.ts diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index 38519d7ca6..5f901250eb 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -115,17 +115,6 @@ export const readJavaScriptArtifactFiles = async ( undefined, issues, ); - const unavailablePath = sourcePaths?.find((path) => - inventory.unavailableFiles.has(path), - ); - if (unavailablePath !== undefined) - throw new ArtifactReaderFailure( - "unavailable", - `Selected inventoried source bytes are unavailable: ${unavailablePath}`, - { - partialObservation: { kind: "artifact-inventory", inventory: snapshot }, - }, - ); if (reader instanceof AsarArtifactReader) { const root = snapshot.nodes.find( ({ artifact_id }) => artifact_id === snapshot.manifest.root_artifact_id, @@ -141,6 +130,25 @@ export const readJavaScriptArtifactFiles = async ( signal, ); } + if (inventory.ambiguousPath !== undefined) + throw new ArtifactReaderFailure( + "path", + `JavaScript artifact path collision: ${JSON.stringify(inventory.ambiguousPath)}. Multiple occurrences share this normalized logical path; inventory retains each occurrence.`, + { + partialObservation: { kind: "artifact-inventory", inventory: snapshot }, + }, + ); + const unavailablePath = sourcePaths?.find((path) => + inventory.unavailableFiles.has(path), + ); + if (unavailablePath !== undefined) + throw new ArtifactReaderFailure( + "unavailable", + `Selected inventoried source bytes are unavailable: ${unavailablePath}`, + { + partialObservation: { kind: "artifact-inventory", inventory: snapshot }, + }, + ); const context: ReadContext = { resources, expected, @@ -415,21 +423,38 @@ const expectedInventory = ( readonly files: ReadonlyMap; readonly containers: ReadonlyMap; readonly unavailableFiles: ReadonlySet; + readonly ambiguousPath: string | undefined; } => { const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node])); const files = new Map(); const containers = new Map(); const unavailableFiles = new Set(); + const seenPaths = new Map(); + let ambiguousPath: string | undefined; for (const occurrence of snapshot.occurrences) { if (occurrence.logical_path === ".") continue; const isNestedAsar = occurrence.entry_kind === "file" && occurrence.logical_path.toLowerCase().endsWith(".asar"); + const kind = relevantKind(occurrence.logical_path); + if ( + occurrence.entry_kind === "file" && + (isNestedAsar || kind !== undefined) + ) { + const available = occurrence.artifact_id !== null; + const priorAvailable = seenPaths.get(occurrence.logical_path); + if (priorAvailable !== undefined) { + // Unavailable siblings cannot bind content to the wrong occurrence. + // Keep them as unknown; any available sibling makes the shared path + // ambiguous for acquisition, regardless of encounter order. + if (priorAvailable || available) + ambiguousPath ??= occurrence.logical_path; + continue; + } + seenPaths.set(occurrence.logical_path, available); + } if (occurrence.artifact_id === null) { - if ( - occurrence.entry_kind === "file" && - relevantKind(occurrence.logical_path) !== undefined - ) + if (occurrence.entry_kind === "file" && kind !== undefined) unavailableFiles.add(occurrence.logical_path); if (isNestedAsar) containers.set(occurrence.logical_path, { @@ -460,7 +485,6 @@ const expectedInventory = ( inventory_artifact_id: node.artifact_id, }, }); - const kind = relevantKind(occurrence.logical_path); if (kind === undefined) continue; files.set(occurrence.logical_path, { path: occurrence.logical_path, @@ -470,7 +494,7 @@ const expectedInventory = ( kind, }); } - return { files, containers, unavailableFiles }; + return { files, containers, unavailableFiles, ambiguousPath }; }; /** Canonical snapshot arrays are already ordered by code-point identity. */ @@ -524,11 +548,33 @@ const createUnanalyzedArtifactLookup = ( coverage: snapshot.coverage, get(path) { if (requested.has(path)) return requested.get(path); - const occurrence = - snapshot.occurrences[ - lowerBound(snapshot.occurrences, path, (entry) => entry.logical_path) - ]; + const index = lowerBound( + snapshot.occurrences, + path, + (entry) => entry.logical_path, + ); + const occurrence = snapshot.occurrences[index]; + const next = snapshot.occurrences[index + 1]; const kind = relevantKind(path); + if ( + occurrence?.logical_path === path && + next?.logical_path === path && + (isOpaque(occurrence) || isUnavailableRelevant(occurrence)) + ) { + const ambiguous: JavaScriptUnanalyzedArtifactFile = { + path, + container_sha256: containerFor(path), + sha256: null, + bytes: null, + inventory_artifact_id: null, + ...(kind === undefined ? {} : { kind }), + limitations: [ + "Multiple inventory occurrences share this normalized path; content identity is unknown.", + ], + }; + requested.set(path, ambiguous); + return ambiguous; + } const id = occurrence?.logical_path === path && (isOpaque(occurrence) || isUnavailableRelevant(occurrence)) diff --git a/tests/acceptance/applications/javascriptAsarCollisionCli.test.ts b/tests/acceptance/applications/javascriptAsarCollisionCli.test.ts new file mode 100644 index 0000000000..185096c49c --- /dev/null +++ b/tests/acceptance/applications/javascriptAsarCollisionCli.test.ts @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { asarHeaderBytes } from "../../fixtures/asarHeader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "reports normalized ASAR collisions as path failures", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-asar-collision-cli-"); + const path = join(root, "twins.asar"); + const entries = [ + ["café.js", "console.log(1)\n"], + ["cafe\u0301.js", "console.log(2)\n"], + ] as const; + let offset = 0; + const files = Object.fromEntries( + entries.map(([name, text]) => { + const bytes = Buffer.from(text); + const record = { + size: bytes.length, + offset: String(offset), + integrity: { + algorithm: "SHA256", + hash: createHash("sha256").update(bytes).digest("hex"), + }, + }; + offset += bytes.length; + return [name, record]; + }), + ); + await writeFile( + path, + Buffer.concat([ + asarHeaderBytes(JSON.stringify({ files })), + ...entries.map(([, text]) => Buffer.from(text)), + ]), + ); + const result = await cli.run({ + arguments: ["analyze-javascript-application", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "artifact_operation_failed", + details: { reason: "path", detail: expect.stringContaining("café.js") }, + }); + expect(result.stdout).not.toContain("changed after inventory"); + }, +); + +cliTest( + "analyzes readable sources when all colliding siblings are unavailable", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-asar-missing-cli-"); + const path = join(root, "missing.asar"); + const main = Buffer.from('require("./café.js");\n'); + const files = { + "main.js": { size: main.length, offset: "0" }, + "café.js": { size: 10, unpacked: true }, + "cafe\u0301.js": { size: 10, unpacked: true }, + }; + await writeFile( + path, + Buffer.concat([asarHeaderBytes(JSON.stringify({ files })), main]), + ); + const result = await cli.run({ + arguments: ["analyze-javascript-application", path, "--json"], + }); + expect(result.exitCode).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + statistics: { parsed_javascript_files: 1 }, + }, + }); + expect(result.stdout).toContain("café.js"); + expect(result.stdout).toContain("unknown"); + }, +); diff --git a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts index 16eecbb6ca..f462f23dff 100644 --- a/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts @@ -1,4 +1,5 @@ import { lstat, mkdir, rm, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { join } from "node:path"; import { createPackage } from "@electron/asar"; @@ -9,6 +10,7 @@ import { readJavaScriptArtifactFiles } from "../../support/javascriptApplication import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { asarHeaderBytes } from "../../fixtures/asarHeader.js"; const fixture = async () => { const root = await createTestTempDirectory("rea-ingestion-content-identity-"); @@ -157,3 +159,187 @@ describe("JavaScript artifact ingestion content identities", () => { } }); }); + +it("refuses normalized ASAR name collisions without claiming changed bytes", async () => { + const root = await createTestTempDirectory("rea-asar-unicode-collision-"); + const archive = join(root, "twins.asar"); + const first = Buffer.from("console.log(1)\n"); + const second = Buffer.from("console.log(2)\n"); + const entry = (contents: Buffer, offset: number) => ({ + size: contents.length, + offset: String(offset), + integrity: { + algorithm: "SHA256", + hash: createHash("sha256").update(contents).digest("hex"), + }, + }); + const header = asarHeaderBytes( + JSON.stringify({ + files: { + "café.js": entry(first, 0), + "cafe\u0301.js": entry(second, first.length), + }, + }), + ); + const bytes = Buffer.concat([header, first, second]); + await writeFile(archive, bytes); + const snapshot = await scanArtifactInventory(archive); + const occurrences = snapshot.occurrences.filter( + ({ logical_path }) => logical_path === "café.js", + ); + expect(occurrences).toHaveLength(2); + expect( + new Set(occurrences.map(({ occurrence_id }) => occurrence_id)).size, + ).toBe(2); + expect( + occurrences.every(({ hash_status }) => hash_status === "verified"), + ).toBe(true); + + const reader = new AsarArtifactReader(archive); + try { + await expect( + readJavaScriptArtifactFiles(reader, snapshot), + ).rejects.toMatchObject({ + reason: "path", + message: expect.stringContaining("café.js"), + partialObservation: { kind: "artifact-inventory", inventory: snapshot }, + }); + } finally { + await reader.close(); + } + + // A genuine root change remains an integrity failure even with a collision. + const changed = Buffer.from(bytes); + changed[changed.length - 1] = (changed[changed.length - 1] ?? 0) ^ 1; + await writeFile(archive, changed); + const changedReader = new AsarArtifactReader(archive); + try { + await expect( + readJavaScriptArtifactFiles(changedReader, snapshot), + ).rejects.toMatchObject({ + reason: "integrity", + }); + } finally { + await changedReader.close(); + } +}); + +it("does not attribute a colliding opaque path to one inventory occurrence", async () => { + const root = await createTestTempDirectory("rea-asar-opaque-collision-"); + const archive = join(root, "opaque.asar"); + const entries = [ + ["main.js", "export const ready = true;"], + ["café.txt", "first"], + ["cafe\u0301.txt", "second"], + ] as const; + let offset = 0; + const files = Object.fromEntries( + entries.map(([name, text]) => { + const bytes = Buffer.from(text); + const entry = { size: bytes.length, offset: String(offset) }; + offset += bytes.length; + return [name, entry]; + }), + ); + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes(JSON.stringify({ files })), + ...entries.map(([, text]) => Buffer.from(text)), + ]), + ); + const snapshot = await scanArtifactInventory(archive); + const reader = new AsarArtifactReader(archive); + try { + const result = await readJavaScriptArtifactFiles(reader, snapshot); + expect(result.files.map(({ path }) => path)).toContain("main.js"); + expect(result.unanalyzed_files?.get("café.txt")).toEqual({ + path: "café.txt", + container_sha256: snapshot.manifest.root_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: null, + limitations: [ + "Multiple inventory occurrences share this normalized path; content identity is unknown.", + ], + }); + } finally { + await reader.close(); + } +}); + +it.each(["unavailable", "packed-first", "packed-last"] as const)( + "handles normalized relevant ASAR collisions with %s content", + async (availability) => { + const root = await createTestTempDirectory("rea-asar-missing-collision-"); + const archive = join(root, "missing.asar"); + const main = Buffer.from("export const ready = true;\n"); + const packed = Buffer.from("export const sibling = true;\n"); + const missing = { size: packed.length, unpacked: true }; + const present = { size: packed.length, offset: String(main.length) }; + const files = { + "main.js": { size: main.length, offset: "0" }, + "cafe\u0301.js": availability === "packed-first" ? present : missing, + "café.js": availability === "packed-last" ? present : missing, + }; + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes(JSON.stringify({ files })), + main, + ...(availability === "unavailable" ? [] : [packed]), + ]), + ); + const snapshot = await scanArtifactInventory(archive); + const reader = new AsarArtifactReader(archive); + try { + const result = readJavaScriptArtifactFiles(reader, snapshot); + if (availability === "unavailable") { + const files = await result; + expect(files.files.map(({ path }) => path)).toEqual(["main.js"]); + expect(files.unanalyzed_files?.get("café.js")).toMatchObject({ + path: "café.js", + inventory_artifact_id: null, + sha256: null, + limitations: [ + expect.stringContaining("Multiple inventory occurrences"), + ], + }); + } else { + await expect(result).rejects.toMatchObject({ + reason: "path", + message: expect.stringContaining("café.js"), + }); + } + } finally { + await reader.close(); + } + }, +); + +it.each(["file-first", "link-first"] as const)( + "rejects mixed-kind normalized ASAR collisions at inventory (%s)", + async (order) => { + const root = await createTestTempDirectory("rea-asar-mixed-collision-"); + const archive = join(root, "mixed.asar"); + const file = { size: 1, offset: "0" }; + const link = { link: "target.txt" }; + // The reader orders raw names, so assigning kinds to each spelling + // exercises both encounter orders even on normalization-folding hosts. + const files = { + "cafe\u0301.txt": order === "file-first" ? file : link, + "café.txt": order === "file-first" ? link : file, + }; + await writeFile( + archive, + Buffer.concat([ + asarHeaderBytes(JSON.stringify({ files })), + Buffer.from("x"), + ]), + ); + await expect(scanArtifactInventory(archive)).rejects.toMatchObject({ + reason: "path", + message: expect.stringContaining("café.txt"), + }); + }, +);