Skip to content

@electron/asar ≥ 4.1.2: createPackageFromStreams computes per-file integrity from the **destination path / CWD** instead of the stream content #446

@mmaietta

Description

@mmaietta

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.jsFilesystem.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:

  1. 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).
  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions