From c9a80bff8491ebe253d6443fed04afd33df8d299 Mon Sep 17 00:00:00 2001 From: Mike Maietta Date: Fri, 12 Jun 2026 09:54:56 -0700 Subject: [PATCH] fix: compute stream package integrity from stream content, not from the destination path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes a bug introduced in `4.1.2` where `createPackageFromStreams` could embed the **wrong per-file integrity hash** in the archive header. The `insertFile` synchronous fast path computes integrity via `fs.readFileSync(p)`, but for the streams API `p` is the file's *destination path inside the archive* (relative), so it resolves against the process CWD. If the CWD happens to contain a file at the same relative path (e.g. `package.json` when building a project from its root), the header records the hash of that unrelated file while the archive stores the correct bytes from the stream. - Apps packed this way and built with the `EnableEmbeddedAsarIntegrityValidation` fuse fail Electron's load-time integrity check and exit immediately at launch. The corruption is silent at pack time and environment-dependent: when no colliding file exists, the `readFileSync` throws `ENOENT` and the code silently falls back to (correct) stream hashing. - Fix ([src/filesystem.ts](src/filesystem.ts), [src/asar.ts](src/asar.ts)): - `insertFile` accepts a new internal option `fromStream`; when set, the `readFileSync` fast path is skipped entirely and integrity is always computed from `streamGenerator()` — the only authoritative source for stream content. - `createPackageFromStreams` passes `{ fromStream: true }`. - Safety guard in the remaining fast path (files API): the buffer returned by `fs.readFileSync(p)` is only trusted when `fileBuffer.length === size` (the size recorded in the header); otherwise it falls through to stream hashing. This protects against stat/read races and any future caller passing a `p` that isn't the real source file. - No performance impact: the stream write path (`streamFilesystem`) never consumed `file.cachedBuffer`, so the fast path provided no write-batching benefit for streams — it only produced the (potentially wrong) hash. `createPackageFromFiles` behavior is unchanged. ## Regression test [test/api-spec.ts](test/api-spec.ts) — `should compute stream package integrity from stream content, not from a same-named file in the CWD`: 1. Creates a source file with known content and a **decoy** file with different content located in the CWD at the same relative path as the archive destination (`index.js`). 2. Packs via `createPackageFromStreams` while `chdir`'d into the decoy directory (emulating building a project from its root). 3. Asserts the archived bytes are the stream content **and** the header integrity hash equals `sha256(stream content)`. Verified the test fails on unpatched code (header hash matched the decoy's sha256) and passes with the fix. Full suite: `yarn lint` clean, `yarn vitest run` 188/188 passing. --- src/asar.ts | 16 ++++++++++++---- src/filesystem.ts | 20 +++++++++++++++----- test/api-spec.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/asar.ts b/src/asar.ts index d60a922..1d375fc 100644 --- a/src/asar.ts +++ b/src/asar.ts @@ -285,10 +285,18 @@ export async function createPackageFromStreams(dest: string, streams: AsarStream mode: stream.stat.mode, unpack: stream.unpacked, }); - return filesystem.insertFile(filename, stream.streamGenerator, stream.unpacked, { - type: 'file', - stat: stream.stat, - }); + return filesystem.insertFile( + filename, + stream.streamGenerator, + stream.unpacked, + { + type: 'file', + stat: stream.stat, + }, + // `filename` is the destination path inside the archive, not a path + // on disk, so integrity must be computed from the stream. + { fromStream: true }, + ); case 'link': links.push({ filename, diff --git a/src/filesystem.ts b/src/filesystem.ts index 9a45cc8..8203e3f 100644 --- a/src/filesystem.ts +++ b/src/filesystem.ts @@ -115,6 +115,12 @@ export class Filesystem { file: CrawledFileType, options: { transform?: (filePath: string) => NodeJS.ReadWriteStream | void; + /** + * Set when the file content comes from a stream rather than a file on + * disk at `p`. In that case `p` is only the destination path inside the + * archive, so integrity must be computed from the stream. + */ + fromStream?: boolean; } = {}, ): Promise { const dirNode = this.searchNodeFromPath(path.dirname(p)) as FilesystemDirectoryEntry; @@ -145,14 +151,18 @@ export class Filesystem { node.executable = true; } - if (size <= BUFFER_HASH_THRESHOLD) { + if (!options.fromStream && size <= BUFFER_HASH_THRESHOLD) { // Fully synchronous fast path — no Promise, no stream, no microtask yield try { const fileBuffer = fs.readFileSync(p); - node.integrity = getFileIntegrityFromBuffer(fileBuffer); - file.cachedBuffer = fileBuffer; - this.offset += BigInt(size); - return Promise.resolve(); + // Only trust the buffer if it matches the size recorded in the header; + // otherwise the file at `p` is not the content being archived. + if (fileBuffer.length === size) { + node.integrity = getFileIntegrityFromBuffer(fileBuffer); + file.cachedBuffer = fileBuffer; + this.offset += BigInt(size); + return Promise.resolve(); + } } catch { // Fall through to stream path } diff --git a/test/api-spec.ts b/test/api-spec.ts index 0144d68..32a7aba 100644 --- a/test/api-spec.ts +++ b/test/api-spec.ts @@ -1,12 +1,15 @@ import { describe, it, beforeEach, expect } from 'vitest'; import { wrappedFs as fs } from '../src/wrapped-fs.js'; +import { createHash } from 'node:crypto'; import os from 'node:os'; +import path from 'node:path'; import { createPackage, createPackageFromStreams, createPackageWithOptions, extractAll, extractFile, + getRawHeader, listPackage, statFile, } from '../src/asar.js'; @@ -219,6 +222,44 @@ describe('api', () => { return compDirs('tmp/extractthis-read-stream-symlink/', src); }); + it('should compute stream package integrity from stream content, not from a same-named file in the CWD', async () => { + const realContent = 'REAL-APP-CONTENT\n'; + const decoyContent = 'DECOY-CONTENT-IN-CWD\n'; + + const work = await fs.mkdtemp(path.join(os.tmpdir(), 'asar-stream-integrity-')); + const srcFile = path.join(work, 'real.js'); + await fs.writeFile(srcFile, realContent); + + // a decoy file in the CWD at the same relative path as the archive destination path + const cwdDir = path.join(work, 'cwd'); + await fs.mkdirp(cwdDir); + await fs.writeFile(path.join(cwdDir, 'index.js'), decoyContent); + + const out = path.join(TEST_APPS_DIR, 'packthis-stream-integrity.asar'); + const stat = await fs.lstat(srcFile); + const originalCwd = process.cwd(); + process.chdir(cwdDir); + try { + await createPackageFromStreams(out, [ + { + path: 'index.js', + type: 'file', + unpacked: false, + streamGenerator: () => fs.createReadStream(srcFile), + stat, + }, + ]); + } finally { + process.chdir(originalCwd); + } + + expect(extractFile(out, 'index.js').toString()).toEqual(realContent); + const header = JSON.parse(getRawHeader(out).headerString); + expect(header.files['index.js'].integrity.hash).toEqual( + createHash('sha256').update(realContent).digest('hex'), + ); + }); + it('should throw when using NodeJS.ReadableStreams with symlink outside package', async () => { const src = 'test/input/packthis-with-bad-symlink/'; const streams = await createReadStreams(src);