From f5013c50ca81795f91fd945fae2fb76f9dbe0f97 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 5 Oct 2026 06:40:47 -0700 Subject: [PATCH] fix(artifacts): refresh cached ASAR headers for each inventory (#574) --- src/artifacts/AsarArtifactReader.ts | 5 +- .../filesystem/asarInventoryFreshness.test.ts | 141 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/filesystem/asarInventoryFreshness.test.ts diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 6828cbd330..16b28f7bb8 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -1,6 +1,6 @@ import { Readable } from "node:stream"; -import { extractFile, listPackage, statFile } from "@electron/asar"; +import { extractFile, listPackage, statFile, uncache } from "@electron/asar"; import { ArtifactReaderFailure, @@ -23,6 +23,8 @@ export class AsarArtifactReader implements ArtifactReader { async *entries(signal?: AbortSignal): AsyncIterable { let paths: string[]; try { + // ASAR caches headers by path even after the archive has been replaced. + uncache(this.path); paths = listPackage(this.path, { isPack: false }).sort((left, right) => left.localeCompare(right, "en"), ); @@ -103,6 +105,7 @@ export class AsarArtifactReader implements ArtifactReader { } close(): Promise { + uncache(this.path); return Promise.resolve(); } diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts new file mode 100644 index 0000000000..acc2a6e899 --- /dev/null +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -0,0 +1,141 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer } from "node:stream/consumers"; + +import { createPackage, listPackage, uncache } from "@electron/asar"; +import { describe, expect, it } from "vitest"; + +import { inventoryArtifact } from "../../../src/application/ArtifactInventory.js"; +import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const CONTENT = "module.exports = 42;\n"; + +const archiveFixture = async () => { + const root = await createTestTempDirectory("rea-asar-freshness-"); + const source = join(root, "source"); + const bundle = join(root, "app"); + await Promise.all([mkdir(source), mkdir(bundle)]); + const archive = join(bundle, "app.asar"); + await writeFile(join(source, "old.js"), CONTENT); + await createPackage(source, archive); + return { + archive, + bundle, + source, + replace: async () => { + await rm(join(source, "old.js")); + await writeFile(join(source, "new.js"), CONTENT); + await createPackage(source, archive); + }, + }; +}; + +describe("ASAR inventory freshness", () => { + it.each(["archive", "bundle"] as const)( + "refreshes a replaced archive when inventorying a %s target", + async (target) => { + const fixture = await archiveFixture(); + try { + const first = await inventoryArtifact(fixture[target]); + const prefix = target === "bundle" ? "app.asar/" : ""; + expect( + first.occurrences.map(({ logical_path }) => logical_path), + ).toContain(`${prefix}old.js`); + expect(await inventoryArtifact(fixture[target])).toEqual(first); + + await fixture.replace(); + const second = await inventoryArtifact(fixture[target]); + expect(second.manifest.root_sha256).not.toBe( + first.manifest.root_sha256, + ); + expect(second.integrity_contradictions).toEqual([]); + const paths = second.occurrences.map( + ({ logical_path }) => logical_path, + ); + expect(paths).toContain(`${prefix}new.js`); + expect(paths).not.toContain(`${prefix}old.js`); + expect(await inventoryArtifact(fixture[target])).toEqual(second); + } finally { + uncache(fixture.archive); + } + }, + ); + + it("refreshes a header cached before the reader was created", async () => { + const fixture = await archiveFixture(); + try { + expect(listPackage(fixture.archive, { isPack: false })).toEqual([ + "/old.js", + ]); + await fixture.replace(); + const inventory = await inventoryArtifact(fixture.archive); + expect( + inventory.occurrences.map(({ logical_path }) => logical_path), + ).toEqual([".", "new.js"]); + } finally { + uncache(fixture.archive); + } + }); + + it("keeps another reader usable after a shared archive reader closes twice", async () => { + const fixture = await archiveFixture(); + await writeFile(join(fixture.source, "second.js"), "second member\n"); + await createPackage(fixture.source, fixture.archive); + const first = new AsarArtifactReader(fixture.archive); + const second = new AsarArtifactReader(fixture.archive); + const firstEntries = first.entries()[Symbol.asyncIterator](); + const secondEntries = second.entries()[Symbol.asyncIterator](); + try { + const firstEntry = await firstEntries.next(); + const secondEntry = await secondEntries.next(); + if (firstEntry.done || secondEntry.done) + throw new Error("Expected an ASAR file entry"); + expect(secondEntry.value.path).toBe("old.js"); + await first.close(); + await first.close(); + expect( + (await buffer(await second.open(secondEntry.value))).toString(), + ).toBe(CONTENT); + const remainingEntry = await secondEntries.next(); + if (remainingEntry.done) throw new Error("Expected a second ASAR entry"); + expect(remainingEntry.value.path).toBe("second.js"); + expect( + (await buffer(await second.open(remainingEntry.value))).toString(), + ).toBe("second member\n"); + expect((await secondEntries.next()).done).toBe(true); + } finally { + await firstEntries.return?.(); + await secondEntries.return?.(); + await first.close(); + await second.close(); + uncache(fixture.archive); + } + }); + + it("releases the selected archive header on close after a cancelled read", async () => { + const fixture = await archiveFixture(); + const reader = new AsarArtifactReader(fixture.archive); + const entries = reader.entries()[Symbol.asyncIterator](); + try { + const entry = await entries.next(); + if (entry.done) throw new Error("Expected an ASAR file entry"); + const controller = new AbortController(); + controller.abort(); + await expect(async () => + reader.open(entry.value, controller.signal), + ).rejects.toMatchObject({ reason: "cancelled" }); + await entries.return?.(); + await reader.close(); + + await fixture.replace(); + expect(listPackage(fixture.archive, { isPack: false })).toEqual([ + "/new.js", + ]); + } finally { + await entries.return?.(); + await reader.close(); + uncache(fixture.archive); + } + }); +});