fix(javascript)!: report ambiguous artifact paths (#7864)

* fix(javascript): report ambiguous artifact paths

* fix(javascript): retain unavailable colliding sources
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 05:10:18 +08:00
1 parent 29945d435d
commit 83cd1e04c9
3 files changed
+334 -21

No files matched your search

@@ -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<string, ExpectedFile>;
readonly containers: ReadonlyMap<string, ExpectedContainer>;
readonly unavailableFiles: ReadonlySet<string>;
readonly ambiguousPath: string | undefined;
} => {
const nodes = new Map(snapshot.nodes.map((node) => [node.artifact_id, node]));
const files = new Map<string, ExpectedFile>();
const containers = new Map<string, ExpectedContainer>();
const unavailableFiles = new Set<string>();
const seenPaths = new Map<string, boolean>();
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))
@@ -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");
},
);
@@ -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"),
});
},
);