From 28e0f4e9ec09ae35b26920e4c6925f48770bf5ed Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:19:04 +0800 Subject: [PATCH] fix(artifacts): preserve archive entry types and host paths (#581) --- .github/workflows/ci.yml | 2 + src/application/JavaScriptArtifactFiles.ts | 4 + src/artifacts/AsarArtifactReader.ts | 19 ++++- .../filesystem/asarInventoryFreshness.test.ts | 80 ++++++++++++++++++- .../javascriptArtifactReconstruction.test.ts | 53 +++++++++++- 5 files changed, 153 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42e714dadd..30d0ae1dad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/application/JavaScriptArtifactFiles.ts b/src/application/JavaScriptArtifactFiles.ts index 72d24265c0..0dd0b82d38 100644 --- a/src/application/JavaScriptArtifactFiles.ts +++ b/src/application/JavaScriptArtifactFiles.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, diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 16b28f7bb8..3e63ab0e39 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -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; 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, diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts index acc2a6e899..5c88bbd340 100644 --- a/tests/boundary/filesystem/asarInventoryFreshness.test.ts +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -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", diff --git a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts index b1ea2e7221..d63992df3f 100644 --- a/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/composition/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -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");