diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index e5ef68112..d08ebb0fe 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -54,6 +54,7 @@ export class AsarArtifactReader implements ArtifactReader { >(); #snapshot: { readonly path: string; readonly sha256: string } | undefined; #snapshotRoot: string | undefined; + #snapshotOperation: Promise = Promise.resolve(); constructor( private readonly path: string, @@ -234,62 +235,20 @@ export class AsarArtifactReader implements ArtifactReader { expectedSha256?: string, signal?: AbortSignal, ): Promise { - abortIfNeeded(signal); - if (this.#snapshot === undefined && this.#snapshotRoot !== undefined) - await this.close(); - if (this.#snapshot === undefined) { - try { - this.#snapshotRoot = await mkdtemp( - join(tmpdir(), "rea-asar-snapshot-"), + await this.#withSnapshotOperation(async () => { + abortIfNeeded(signal); + if (this.#snapshot === undefined && this.#snapshotRoot !== undefined) + await this.#closeSnapshot(); + if (this.#snapshot === undefined) await this.#captureSnapshot(signal); + if ( + expectedSha256 !== undefined && + this.#snapshot?.sha256 !== expectedSha256 + ) + throw new ArtifactReaderFailure( + "integrity", + `ASAR container changed before interpretation: ${this.path}`, ); - const snapshotPath = join(this.#snapshotRoot, "container.asar"); - const hash = createHash("sha256"); - const handle = await open( - this.path, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, - ); - const source = handle.createReadStream(); - const digesting = new Transform({ - transform(chunk: Buffer, _encoding, callback) { - hash.update(chunk); - callback(null, chunk); - }, - }); - await pipeline( - source, - digesting, - createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }), - signal === undefined ? {} : { signal }, - ); - this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") }; - } catch (cause: unknown) { - const primary = - signal?.aborted === true - ? new ArtifactReaderFailure( - "cancelled", - "ASAR operation cancelled", - { cause }, - ) - : asarFailure(this.path, "snapshot", cause); - try { - await this.close(); - } catch (cleanupCause: unknown) { - throw ArtifactReaderFailure.withCleanup( - primary, - ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path), - ); - } - throw primary; - } - } - if ( - expectedSha256 !== undefined && - this.#snapshot.sha256 !== expectedSha256 - ) - throw new ArtifactReaderFailure( - "integrity", - `ASAR container changed before interpretation: ${this.path}`, - ); + }, signal); } /** Read the same captured container that supplies headers and packed members. */ @@ -302,30 +261,86 @@ export class AsarArtifactReader implements ArtifactReader { } async close(): Promise { - let cleanupFailure: ArtifactReaderFailure | undefined; - for (const [handle, { path }] of this.#unpackedHandles) { + await this.#withSnapshotOperation(async () => { + let cleanupFailure: ArtifactReaderFailure | undefined; + for (const [handle, { path }] of this.#unpackedHandles) { + try { + await this.#closeUnpackedHandle(handle); + } catch (cause: unknown) { + cleanupFailure = ArtifactReaderFailure.withCleanup( + cleanupFailure ?? cause, + ArtifactReaderFailure.cleanupObservation(cause, path), + ); + } + } try { - await this.#closeUnpackedHandle(handle); + await this.#closeSnapshot(); } catch (cause: unknown) { cleanupFailure = ArtifactReaderFailure.withCleanup( cleanupFailure ?? cause, - ArtifactReaderFailure.cleanupObservation(cause, path), + ArtifactReaderFailure.cleanupObservation(cause, this.path), ); } + if (cleanupFailure !== undefined) throw cleanupFailure; + }); + } + + async #captureSnapshot(signal?: AbortSignal): Promise { + try { + this.#snapshotRoot = await mkdtemp(join(tmpdir(), "rea-asar-snapshot-")); + const snapshotPath = join(this.#snapshotRoot, "container.asar"); + const hash = createHash("sha256"); + const handle = await open( + this.path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const source = handle.createReadStream(); + const digesting = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + hash.update(chunk); + callback(null, chunk); + }, + }); + await pipeline( + source, + digesting, + createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }), + signal === undefined ? {} : { signal }, + ); + this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") }; + } catch (cause: unknown) { + const primary = + signal?.aborted === true + ? new ArtifactReaderFailure("cancelled", "ASAR operation cancelled", { + cause, + }) + : asarFailure(this.path, "snapshot", cause); + try { + await this.#closeSnapshot(); + } catch (cleanupCause: unknown) { + throw ArtifactReaderFailure.withCleanup( + primary, + ArtifactReaderFailure.cleanupObservation( + cleanupCause, + this.#snapshotRoot ?? this.path, + ), + ); + } + throw primary; } + } + + async #closeSnapshot(): Promise { const root = this.#snapshotRoot; if (root !== undefined) { try { await this.removeSnapshot(root); if (this.#snapshotRoot === root) this.#snapshotRoot = undefined; } catch (cause: unknown) { - cleanupFailure = ArtifactReaderFailure.withCleanup( - cleanupFailure ?? cause, - { - reason: `Could not remove ASAR snapshot: ${root}`, - resources: [root], - }, - ); + throw ArtifactReaderFailure.withCleanup(cause, { + reason: `Could not remove ASAR snapshot: ${root}`, + resources: [root], + }); } } if (root === undefined || this.#snapshotRoot === undefined) { @@ -334,7 +349,29 @@ export class AsarArtifactReader implements ArtifactReader { this.#resetArchiveState(); this.#entries.clear(); } - if (cleanupFailure !== undefined) throw cleanupFailure; + } + + async #withSnapshotOperation( + operation: () => Promise, + signal?: AbortSignal, + ): Promise { + const previous = this.#snapshotOperation; + let release!: () => void; + this.#snapshotOperation = new Promise((resolve) => { + release = resolve; + }); + try { + await waitForOperation(previous, signal); + } catch (cause: unknown) { + void previous.then(release, release); + throw cause; + } + try { + abortIfNeeded(signal); + return await operation(); + } finally { + release(); + } } #snapshotPath(): string { @@ -511,6 +548,35 @@ const abortIfNeeded = (signal?: AbortSignal): void => { throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"); }; +const waitForOperation = async ( + operation: Promise, + signal?: AbortSignal, +): Promise => { + if (signal === undefined) return operation; + abortIfNeeded(signal); + await new Promise((resolve, reject) => { + const removeAbortListener = (): void => + signal.removeEventListener("abort", onAbort); + const onAbort = (): void => { + removeAbortListener(); + reject( + new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"), + ); + }; + signal.addEventListener("abort", onAbort, { once: true }); + operation.then( + () => { + removeAbortListener(); + resolve(); + }, + (cause: unknown) => { + removeAbortListener(); + reject(cause); + }, + ); + }); +}; + const toArtifactPath = (listed: string): string => { const portable = sep === "\\" ? listed.replaceAll("\\", "/") : listed; return portable.replace(/^\/+|\/+$/gu, ""); diff --git a/tests/boundary/filesystem/asarInventoryFreshness.test.ts b/tests/boundary/filesystem/asarInventoryFreshness.test.ts index 1ad207dff..24d778bfc 100644 --- a/tests/boundary/filesystem/asarInventoryFreshness.test.ts +++ b/tests/boundary/filesystem/asarInventoryFreshness.test.ts @@ -171,6 +171,54 @@ describe("ASAR inventory freshness", () => { ); }); +describe("ASAR snapshot operation ownership", () => { + it("waits for preparation before close and cancels a queued caller", async () => { + const fixture = await archiveFixture(); + let allowRemoval: (() => void) | undefined; + let removalStarted: (() => void) | undefined; + const removalGate = new Promise((resolve) => { + allowRemoval = resolve; + }); + const removalStartedPromise = new Promise((resolve) => { + removalStarted = resolve; + }); + const reader = new AsarArtifactReader( + fixture.archive, + undefined, + async (path) => { + removalStarted?.(); + await removalGate; + await rm(path, { recursive: true, force: true }); + }, + ); + let prepared = false; + const preparing = reader.prepareContainer().then(() => { + prepared = true; + }); + const closing = reader.close(); + const controller = new AbortController(); + try { + const closeReachedRemoval = Promise.race([ + removalStartedPromise, + closing.then(() => { + throw new Error("close completed before snapshot removal began"); + }), + ]); + await Promise.all([preparing, closeReachedRemoval]); + expect(prepared).toBe(true); + const queued = reader.prepareContainer(undefined, controller.signal); + controller.abort(); + await expect(queued).rejects.toMatchObject({ reason: "cancelled" }); + allowRemoval?.(); + await Promise.all([preparing, closing]); + await expect(reader.prepareContainer()).resolves.toBeUndefined(); + } finally { + allowRemoval?.(); + await reader.close(); + } + }); +}); + describe("ASAR captured-byte identity", () => { it("rejects same-size nested-container replacement between hashing and interpretation", async () => { const fixture = await archiveFixture(); diff --git a/tests/process-global/asarSnapshotOperation.test.ts b/tests/process-global/asarSnapshotOperation.test.ts new file mode 100644 index 000000000..eaaee9686 --- /dev/null +++ b/tests/process-global/asarSnapshotOperation.test.ts @@ -0,0 +1,64 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buffer } from "node:stream/consumers"; + +import { createPackage } from "@electron/asar"; +import { afterEach, expect, it, vi } from "vitest"; + +import { AsarArtifactReader } from "../../src/artifacts/AsarArtifactReader.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +it("serializes concurrent ASAR snapshot preparation and cleans its single owned root", async () => { + const root = await createTestTempDirectory("rea-asar-snapshot-owner-"); + vi.stubEnv("TMPDIR", root); + vi.stubEnv("TMP", root); + vi.stubEnv("TEMP", root); + + const source = join(root, "source"); + const archive = join(root, "app.asar"); + await mkdir(source); + await writeFile(join(source, "main.js"), "module.exports = 42;\n"); + await createPackage(source, archive); + + const reader = new AsarArtifactReader(archive); + const iterator = reader.entries()[Symbol.asyncIterator](); + try { + const expectedDigest = createHash("sha256") + .update(await readFile(archive)) + .digest("hex"); + const openingContainer = reader.openContainer(); + const readingEntry = iterator.next(); + const preparingSnapshot = reader.prepareContainer(expectedDigest); + const [snapshot, firstEntry] = await Promise.all([ + openingContainer, + readingEntry, + preparingSnapshot, + ]); + if (firstEntry.done) throw new Error("Expected an ASAR member"); + expect(await buffer(snapshot)).toEqual(await readFile(archive)); + + const mismatch = reader.prepareContainer("0".repeat(64)); + const matching = reader.prepareContainer(expectedDigest); + await expect(mismatch).rejects.toMatchObject({ reason: "integrity" }); + await expect(matching).resolves.toBeUndefined(); + + const snapshotRoots = async (): Promise => + (await readdir(root)).filter((name) => + name.startsWith("rea-asar-snapshot-"), + ); + expect(await snapshotRoots()).toHaveLength(1); + await reader.close(); + expect(await snapshotRoots()).toEqual([]); + } finally { + try { + await iterator.return?.(); + } finally { + await reader.close(); + } + } +});