Full disclosure: debugging and write-up performed with Claude Opus.
Reproduced, while attempting to upgrade electron/asar version, on Windows during electron-builder e2e auto-updater tests (build->install exe->launch->auto-update->launch + verify version->uninstall)
https://github.com/electron-userland/electron-builder/actions/runs/27425068451/job/81060912131?pr=9889
Impact radius might be fairly limited since it's only through the Streaming API.
I'll get started on a fix, but I wanted to report the deeper details here in case anyone else runs into this issue.
Summary
Filesystem.insertFile contains a synchronous "fast path" (added in 4.1.2) that computes a packed file's embedded integrity by reading the file from disk with fs.readFileSync(p), where p is the file's destination path inside the archive.
When packing via createPackageFromStreams, the file's content comes from the supplied stream, not from a file located at p. Because p is a path relative to the archive root, fs.readFileSync(p) resolves it against the current working directory. If the CWD happens to contain a file at that same relative path (extremely common — e.g. package.json, index.js, node_modules/<dep>/package.json when building a project from its root), the archive header records the integrity hash of the wrong file while the archive stores the correct bytes from the stream.
The result is an .asar whose embedded per-file integrity does not match its own contents. When the consuming Electron app is built with the EnableEmbeddedAsarIntegrityValidation fuse, Electron validates file contents against the header integrity at load time, detects the mismatch, and refuses to load app.asar — the app exits immediately at launch with no output.
If the CWD does not contain a colliding path, fs.readFileSync(p) throws ENOENT, is silently caught, and the code falls back to the correct stream-based hashing. This makes the corruption silent and environment-dependent: it only manifests when a same-named file exists in the CWD, so it can pass in one environment and fail in another with no error at pack time.
Affected versions
| Version |
Status |
| ≤ 4.1.1 |
✅ Not affected — integrity is computed from the stream (getFileIntegrity(streamGenerator())) |
| 4.1.2 |
❌ Affected — fast path introduced |
| 4.2.0 (latest) |
❌ Affected |
Root cause
lib/filesystem.js → Filesystem.insertFile (line numbers from 4.2.0):
insertFile(p, streamGenerator, shouldUnpack, file, options = {}) {
// ...
const size = file.stat.size;
// ...
node.size = size;
node.offset = this.offset.toString();
// ...
if (size <= BUFFER_HASH_THRESHOLD) { // 2 MB
// Fully synchronous fast path — no Promise, no stream, no microtask yield
try {
const fileBuffer = fs.readFileSync(p); // <-- reads `p` from disk (the DEST path / CWD)
node.integrity = getFileIntegrityFromBuffer(fileBuffer);
file.cachedBuffer = fileBuffer;
this.offset += BigInt(size);
return Promise.resolve();
}
catch {
// Fall through to stream path
}
}
return getFileIntegrity(streamGenerator()).then((integrity) => {
node.integrity = integrity;
this.offset += BigInt(size);
});
}
This is correct for createPackageFromFiles, which calls:
await filesystem.insertFile(filename, () => fs.createReadStream(filename), shouldUnpack, file, options);
— here p === filename is the real on-disk source path, and the stream reads the same file, so fs.readFileSync(p) is a valid optimization.
It is incorrect for createPackageFromStreams, which calls:
const filename = path.normalize(destinationPath); // destination path inside the archive (relative)
// ...
return filesystem.insertFile(filename, stream.streamGenerator, stream.unpacked, {
type: 'file',
stat: stream.stat,
});
— here p is the destination path and stream.streamGenerator reads from an unrelated source. The fast path therefore hashes CWD/<destination-path> (whatever happens to be there) rather than the stream content.
Note the archive bytes are written separately by streamFilesystem from streamGenerator() (correct), so only the header integrity is wrong — the two disagree.
Minimal reproduction
import { createHash } from "crypto"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import * as asar from "@electron/asar" // 4.2.0
const work = fs.mkdtempSync(path.join(os.tmpdir(), "asar-bug-"))
const srcDir = path.join(work, "src")
const cwdDir = path.join(work, "cwd")
fs.mkdirSync(srcDir); fs.mkdirSync(cwdDir)
const REAL = "REAL-APP-CONTENT\n"
const DECOY = "DECOY-CONTENT-IN-CWD\n"
fs.writeFileSync(path.join(srcDir, "real.js"), REAL)
// a file in the CWD at the same *relative destination path* used below ("index.js")
fs.writeFileSync(path.join(cwdDir, "index.js"), DECOY)
const streams = [{
path: "index.js", // destination path inside the archive
type: "file",
unpacked: false,
streamGenerator: () => fs.createReadStream(path.join(srcDir, "real.js")), // real content
stat: fs.statSync(path.join(srcDir, "real.js")),
}]
const outAsar = path.join(work, "app.asar")
const orig = process.cwd()
process.chdir(cwdDir) // emulate building from a project directory
try { await asar.createPackageFromStreams(outAsar, streams) }
finally { process.chdir(orig) }
const archivedBytes = asar.extractFile(outAsar, "index.js").toString()
const headerHash = JSON.parse(asar.getRawHeader(outAsar).headerString).files["index.js"].integrity.hash
console.log("archived bytes are REAL: ", archivedBytes === REAL) // true
console.log("header integrity == sha256(REAL):", headerHash === createHash("sha256").update(REAL).digest("hex")) // false
console.log("header integrity == sha256(DECOY):", headerHash === createHash("sha256").update(DECOY).digest("hex")) // true <-- BUG
Output:
archived bytes are REAL: true
header integrity == sha256(REAL): false
header integrity == sha256(DECOY): true
The header integrity matches the decoy file that happened to be in the CWD, not the bytes actually written to the archive.
Impact
- Tools that pack
.asar archives from streams (e.g. electron-builder, which uses createPackageFromStreams to support content transforms) produce archives whose embedded per-file integrity does not match the archived content whenever the build CWD contains a same-named relative path. Building a project from its own root directory reliably triggers this for top-level files such as package.json.
- Apps built with the
EnableEmbeddedAsarIntegrityValidation fuse (recommended for security, and required on Windows for ASAR integrity) fail to launch: Electron rejects app.asar before any application JavaScript runs.
- The failure is silent at pack time and depends on the contents of the CWD, making it hard to diagnose.
Suggested fix
Per-file integrity must be derived from the same source as the bytes written to the archive. For the streams API the only authoritative source is the stream, so the buffer fast path must not be used there. Options:
- Only take the
fs.readFileSync(p) fast path when p is known to be the real on-disk source (i.e. the createPackageFromFiles path). Have createPackageFromStreams pass an explicit flag (e.g. insertFile(..., { fromStream: true })) and guard the fast path with if (!options.fromStream && size <= BUFFER_HASH_THRESHOLD).
- Or compute the fast-path buffer from the stream (
streamGenerator()) rather than from fs.readFileSync(p) when packing streams.
Either keeps the 4.2.0 batched-write performance improvement while restoring correct integrity for stream-packed archives.
Workaround
Pin @electron/asar to 4.1.1 (the last release that hashes the stream content via getFileIntegrity(streamGenerator())).
Environment
@electron/asar: 4.1.2 → 4.2.0 (latest)
- Node.js: 22.x
- Observed via electron-builder packaging with
enableEmbeddedAsarIntegrityValidation: true + onlyLoadAppFromAsar: true; the packaged Electron app exits at launch with no stdout.
Full disclosure: debugging and write-up performed with Claude Opus.
Reproduced, while attempting to upgrade electron/asar version, on Windows during electron-builder e2e auto-updater tests (build->install exe->launch->auto-update->launch + verify version->uninstall)
https://github.com/electron-userland/electron-builder/actions/runs/27425068451/job/81060912131?pr=9889
Impact radius might be fairly limited since it's only through the Streaming API.
I'll get started on a fix, but I wanted to report the deeper details here in case anyone else runs into this issue.
Summary
Filesystem.insertFilecontains a synchronous "fast path" (added in 4.1.2) that computes a packed file's embedded integrity by reading the file from disk withfs.readFileSync(p), wherepis the file's destination path inside the archive.When packing via
createPackageFromStreams, the file's content comes from the supplied stream, not from a file located atp. Becausepis a path relative to the archive root,fs.readFileSync(p)resolves it against the current working directory. If the CWD happens to contain a file at that same relative path (extremely common — e.g.package.json,index.js,node_modules/<dep>/package.jsonwhen building a project from its root), the archive header records the integrity hash of the wrong file while the archive stores the correct bytes from the stream.The result is an
.asarwhose embedded per-file integrity does not match its own contents. When the consuming Electron app is built with theEnableEmbeddedAsarIntegrityValidationfuse, Electron validates file contents against the header integrity at load time, detects the mismatch, and refuses to loadapp.asar— the app exits immediately at launch with no output.If the CWD does not contain a colliding path,
fs.readFileSync(p)throwsENOENT, is silently caught, and the code falls back to the correct stream-based hashing. This makes the corruption silent and environment-dependent: it only manifests when a same-named file exists in the CWD, so it can pass in one environment and fail in another with no error at pack time.Affected versions
getFileIntegrity(streamGenerator()))Root cause
lib/filesystem.js→Filesystem.insertFile(line numbers from 4.2.0):This is correct for
createPackageFromFiles, which calls:— here
p === filenameis the real on-disk source path, and the stream reads the same file, sofs.readFileSync(p)is a valid optimization.It is incorrect for
createPackageFromStreams, which calls:— here
pis the destination path andstream.streamGeneratorreads from an unrelated source. The fast path therefore hashesCWD/<destination-path>(whatever happens to be there) rather than the stream content.Note the archive bytes are written separately by
streamFilesystemfromstreamGenerator()(correct), so only the header integrity is wrong — the two disagree.Minimal reproduction
Output:
The header integrity matches the decoy file that happened to be in the CWD, not the bytes actually written to the archive.
Impact
.asararchives from streams (e.g. electron-builder, which usescreatePackageFromStreamsto support content transforms) produce archives whose embedded per-file integrity does not match the archived content whenever the build CWD contains a same-named relative path. Building a project from its own root directory reliably triggers this for top-level files such aspackage.json.EnableEmbeddedAsarIntegrityValidationfuse (recommended for security, and required on Windows for ASAR integrity) fail to launch: Electron rejectsapp.asarbefore any application JavaScript runs.Suggested fix
Per-file integrity must be derived from the same source as the bytes written to the archive. For the streams API the only authoritative source is the stream, so the buffer fast path must not be used there. Options:
fs.readFileSync(p)fast path whenpis known to be the real on-disk source (i.e. thecreatePackageFromFilespath). HavecreatePackageFromStreamspass an explicit flag (e.g.insertFile(..., { fromStream: true })) and guard the fast path withif (!options.fromStream && size <= BUFFER_HASH_THRESHOLD).streamGenerator()) rather than fromfs.readFileSync(p)when packing streams.Either keeps the 4.2.0 batched-write performance improvement while restoring correct integrity for stream-packed archives.
Workaround
Pin
@electron/asarto4.1.1(the last release that hashes the stream content viagetFileIntegrity(streamGenerator())).Environment
@electron/asar: 4.1.2 → 4.2.0 (latest)enableEmbeddedAsarIntegrityValidation: true+onlyLoadAppFromAsar: true; the packaged Electron app exits at launch with no stdout.