mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(javascript)!: report ambiguous artifact paths (#7864)
* fix(javascript): report ambiguous artifact paths * fix(javascript): retain unavailable colliding sources
This commit is contained in:
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"),
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user