* fix(javascript): report ambiguous artifact paths * fix(javascript): retain unavailable colliding sources
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { lstat, mkdir, rm, writeFile } from "node:fs/promises";
|
|
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
|
|
import { createPackage } from "@electron/asar";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
|
|
import { readJavaScriptArtifactFiles } from "../../support/javascriptApplicationScope.js";
|
|
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-");
|
|
const source = join(root, "source");
|
|
const bundle = join(root, "bundle");
|
|
await Promise.all([mkdir(source), mkdir(bundle)]);
|
|
await writeFile(join(source, "main.js"), "module.exports = 42;\n");
|
|
await writeFile(join(source, "asset.txt"), "original asset");
|
|
const archive = join(bundle, "app.asar");
|
|
await createPackage(source, archive);
|
|
return { source, bundle, archive };
|
|
};
|
|
|
|
describe("JavaScript artifact ingestion content identities", () => {
|
|
it.each([
|
|
["changed-asset", "directory"],
|
|
["added-asset", "directory"],
|
|
["changed-asset", "asar"],
|
|
["added-asset", "asar"],
|
|
])(
|
|
"rejects a rebuilt container with %s for a %s input and unchanged source",
|
|
async (change, format) => {
|
|
const { source, bundle, archive } = await fixture();
|
|
const snapshot = await scanArtifactInventory(
|
|
format === "asar" ? archive : bundle,
|
|
);
|
|
await writeFile(
|
|
join(source, change === "changed-asset" ? "asset.txt" : "new.txt"),
|
|
"rebuilt asset",
|
|
);
|
|
await createPackage(source, archive);
|
|
const reader =
|
|
format === "asar"
|
|
? new AsarArtifactReader(archive)
|
|
: new DirectoryArtifactReader(bundle);
|
|
try {
|
|
await expect(
|
|
readJavaScriptArtifactFiles(reader, snapshot),
|
|
).rejects.toMatchObject({ reason: "integrity" });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects changed opaque native-addon bytes after inventory", async () => {
|
|
const root = await createTestTempDirectory("rea-ingestion-native-bytes-");
|
|
const addon = join(root, "addon.node");
|
|
await writeFile(addon, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 1]));
|
|
const snapshot = await scanArtifactInventory(root);
|
|
await writeFile(addon, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 2]));
|
|
const reader = new DirectoryArtifactReader(root);
|
|
try {
|
|
await expect(
|
|
readJavaScriptArtifactFiles(reader, snapshot),
|
|
).rejects.toMatchObject({ reason: "integrity" });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("preserves an unchanged direct ASAR raw identity and source", async () => {
|
|
const { archive } = await fixture();
|
|
const snapshot = await scanArtifactInventory(archive);
|
|
const reader = new AsarArtifactReader(archive);
|
|
try {
|
|
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
|
expect(files.containers).toEqual([]);
|
|
expect(files.files.find(({ path }) => path === "main.js")).toMatchObject({
|
|
container_sha256: snapshot.manifest.root_sha256,
|
|
text: { included: true, value: "module.exports = 42;\n" },
|
|
});
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("rejects a same-size ASAR replacement against the inventoried root digest", async () => {
|
|
const { source, archive } = await fixture();
|
|
const snapshot = await scanArtifactInventory(archive);
|
|
const original = await lstat(archive);
|
|
await writeFile(join(source, "main.js"), "module.exports = 43;\n");
|
|
await createPackage(source, archive);
|
|
expect((await lstat(archive)).size).toBe(original.size);
|
|
|
|
const reader = new AsarArtifactReader(archive);
|
|
try {
|
|
await expect(
|
|
readJavaScriptArtifactFiles(reader, snapshot),
|
|
).rejects.toMatchObject({ reason: "integrity" });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("preserves typed cancellation when the direct ASAR filesystem stream aborts", async () => {
|
|
const { archive } = await fixture();
|
|
const snapshot = await scanArtifactInventory(archive);
|
|
const reader = new AsarArtifactReader(archive);
|
|
const controller = new AbortController();
|
|
try {
|
|
const reading = readJavaScriptArtifactFiles(
|
|
reader,
|
|
snapshot,
|
|
controller.signal,
|
|
);
|
|
controller.abort();
|
|
await expect(reading).rejects.toMatchObject({ reason: "cancelled" });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("retains filesystem error context in typed IO failures", async () => {
|
|
const { archive } = await fixture();
|
|
const snapshot = await scanArtifactInventory(archive);
|
|
await rm(archive);
|
|
const reader = new AsarArtifactReader(archive);
|
|
try {
|
|
await expect(
|
|
readJavaScriptArtifactFiles(reader, snapshot),
|
|
).rejects.toMatchObject({ reason: "io", cause: { code: "ENOENT" } });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
|
|
it("preserves unchanged container and opaque addon identities", async () => {
|
|
const { bundle } = await fixture();
|
|
await writeFile(join(bundle, "addon.node"), Buffer.from([1, 2, 3]));
|
|
const snapshot = await scanArtifactInventory(bundle);
|
|
const reader = new DirectoryArtifactReader(bundle);
|
|
try {
|
|
const files = await readJavaScriptArtifactFiles(reader, snapshot);
|
|
expect(files.containers).toHaveLength(1);
|
|
expect(
|
|
files.files.find(({ path }) => path === "addon.node"),
|
|
).toMatchObject({
|
|
text: { included: false, reason: "not-applicable" },
|
|
});
|
|
expect(
|
|
files.files.find(({ path }) => path === "app.asar/main.js")?.text,
|
|
).toEqual({ included: true, value: "module.exports = 42;\n" });
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
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"),
|
|
});
|
|
},
|
|
);
|