Files
reverse-engineering-anything/tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts
T
morluto 83cd1e04c9 fix(javascript)!: report ambiguous artifact paths (#7864)
* fix(javascript): report ambiguous artifact paths

* fix(javascript): retain unavailable colliding sources
2026-10-29 05:10:18 +08:00

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"),
});
},
);