fix(artifacts): preserve archive entry types and host paths (#581)

This commit is contained in:
morluto authored and GitHub committed 2026-10-05 23:19:04 +08:00
1 parent f73bb0fe11
commit 28e0f4e9ec
5 files changed
+153 -5

No files matched your search

+2
View File
@@ -339,6 +339,8 @@ jobs:
run: >-
npx vitest run --coverage.enabled=false
tests/boundary/filesystem/binaryTarget.test.ts
tests/boundary/filesystem/asarInventoryFreshness.test.ts
tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts
tests/conformance/providers/ghidra/windowsCapabilities.test.ts
tests/conformance/providers/ghidra/ghidraWindowsFixture.test.ts
src/ghidra/GhidraInstallation.test.ts
@@ -255,6 +255,10 @@ const expectedInventory = (
"integrity",
`Artifact inventory node is missing: ${occurrence.logical_path}`,
);
// Inventory occurrences can have content identities for directories too.
// Suffixes only describe file candidates; a directory such as
// node_modules/@zip.js remains a graph node and must never be opened.
if (occurrence.entry_kind !== "file") continue;
if (occurrence.logical_path.toLowerCase().endsWith(".asar"))
containers.set(occurrence.logical_path, {
path: occurrence.logical_path,
+16 -3
View File
@@ -1,3 +1,4 @@
import { sep } from "node:path";
import { Readable } from "node:stream";
import { extractFile, listPackage, statFile, uncache } from "@electron/asar";
@@ -33,11 +34,18 @@ export class AsarArtifactReader implements ArtifactReader {
}
for (const listed of paths) {
abortIfNeeded(signal);
const path = listed.replace(/^\/+|\/+$/gu, "");
// @electron/asar returns paths assembled with the host path module.
// Keep that spelling for its API calls, but expose archive paths with
// portable separators. On POSIX, backslashes can be literal filename
// characters and must remain untouched.
const path = toArtifactPath(listed);
if (path.length === 0) continue;
const providerPath = listed.startsWith(sep)
? listed.slice(sep.length)
: listed;
let metadata: ReturnType<typeof statFile>;
try {
metadata = statFile(this.path, path, false);
metadata = statFile(this.path, providerPath, false);
} catch (cause: unknown) {
throw asarFailure(this.path, `stat ${path}`, cause);
}
@@ -68,7 +76,7 @@ export class AsarArtifactReader implements ArtifactReader {
: [
"Official ASAR extraction buffers each individually bounded file.",
],
adapterKey: path,
adapterKey: providerPath,
};
}
}
@@ -119,6 +127,11 @@ const abortIfNeeded = (signal?: AbortSignal): void => {
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
};
const toArtifactPath = (listed: string): string => {
const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed;
return portable.replace(/^\/+|\/+$/gu, "");
};
const asarFailure = (
path: string,
operation: string,
@@ -1,4 +1,4 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { buffer } from "node:stream/consumers";
@@ -31,6 +31,84 @@ const archiveFixture = async () => {
};
};
describe("ASAR path boundary", () => {
it("keeps inventory paths portable and opens nested entries from a packed archive", async () => {
const root = await createTestTempDirectory("rea-asar-paths-");
const source = join(root, "source");
await mkdir(join(source, "nested"), { recursive: true });
await writeFile(join(source, "nested", "main.js"), CONTENT);
const archive = join(root, "paths.asar");
await createPackage(source, archive);
const reader = new AsarArtifactReader(archive);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries.map(({ path, kind }) => [path, kind])).toEqual([
["nested", "directory"],
["nested/main.js", "file"],
]);
const nestedFile = entries.find(({ path }) => path === "nested/main.js");
if (!nestedFile) throw new Error("Expected packed nested ASAR file");
expect((await buffer(await reader.open(nestedFile))).toString()).toBe(
CONTENT,
);
} finally {
await reader.close();
}
});
it.skipIf(process.platform === "win32")(
"reports packed ASAR symlinks without following them",
async () => {
const root = await createTestTempDirectory("rea-asar-symlink-");
const source = join(root, "source");
await mkdir(join(source, "nested"), { recursive: true });
await writeFile(join(source, "nested", "main.js"), CONTENT);
await symlink("nested/main.js", join(source, "alias.js"));
const archive = join(root, "symlink.asar");
await createPackage(source, archive);
const reader = new AsarArtifactReader(archive);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries.find(({ path }) => path === "alias.js")).toMatchObject({
kind: "symlink",
path: "alias.js",
});
} finally {
await reader.close();
}
},
);
it.skipIf(process.platform === "win32")(
"preserves literal backslashes in POSIX ASAR filenames",
async () => {
const root = await createTestTempDirectory("rea-asar-backslash-");
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "literal\\name.js"), CONTENT);
const archive = join(root, "backslash.asar");
await createPackage(source, archive);
const reader = new AsarArtifactReader(archive);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
const file = entries.find(({ path }) => path === "literal\\name.js");
if (!file) throw new Error("Expected literal-backslash ASAR entry");
expect((await buffer(await reader.open(file))).toString()).toBe(
CONTENT,
);
} finally {
await reader.close();
}
},
);
});
describe("ASAR inventory freshness", () => {
it.each(["archive", "bundle"] as const)(
"refreshes a replaced archive when inventorying a %s target",
@@ -1,4 +1,4 @@
import { mkdir, rm } from "node:fs/promises";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createPackageWithOptions } from "@electron/asar";
@@ -155,6 +155,57 @@ it("produces deterministic ASAR graphs with unpacked native linkage and complete
);
});
it("keeps suffix-named ASAR directories in the graph and analyzes their files", async () => {
const root = await createTestTempDirectory(
"rea-javascript-asar-suffix-directory-",
);
const source = join(root, "source");
const zipDirectory = join(source, "node_modules", "@zip.js");
await mkdir(zipDirectory, { recursive: true });
await writeJavaScriptArtifactFixture(source);
await writeFile(
join(zipDirectory, "entry.js"),
"export const packed = true;\n",
);
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, {});
const snapshot = await scanArtifactInventory(archive);
const zipDirectoryOccurrence = snapshot.occurrences.find(
({ logical_path }) => logical_path === "node_modules/@zip.js",
);
expect(zipDirectoryOccurrence).toMatchObject({
entry_kind: "directory",
artifact_id: expect.stringMatching(/^art_[a-f0-9]{64}$/u),
});
expect(
snapshot.nodes.some(
({ artifact_id }) => artifact_id === zipDirectoryOccurrence?.artifact_id,
),
).toBe(true);
const result = await reconstructJavaScriptArtifact({
input_path: archive,
format: "asar",
});
expect(result.statistics.parsed_javascript_files).toBeGreaterThan(0);
expect(result.graph.nodes).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "javascript-asset",
observations: expect.arrayContaining([
expect.objectContaining({
properties: expect.objectContaining({
path: "node_modules/@zip.js/entry.js",
}),
}),
]),
}),
]),
);
});
it("keeps ASAR analysis usable when unpacked native companion bytes are absent", async () => {
const root = await createTestTempDirectory("rea-javascript-asar-missing-");
const source = join(root, "source");