Skip to content

fix: extract archives with 2 GiB or more of data - #464

Open
jmrplens wants to merge 3 commits into
electron:mainfrom
jmrplens:fix/extract-large-archives
Open

jmrplens wants to merge 3 commits into
electron:mainfrom
jmrplens:fix/extract-large-archives

Conversation

@jmrplens

@jmrplens jmrplens commented Aug 7, 2026

Copy link
Copy Markdown

What happens

asar extract fails on any archive whose data section reaches 2 GiB, before
writing a single file:

RangeError [ERR_OUT_OF_RANGE]: The value of "length" is out of range.
It must be >= 0. Received -348492367
    at Object.readSync (node:fs:726:3)
    at extractAll (.../@electron/asar/lib/asar.js:245:16)

-348492367 is 3946474929 - 2^32, the size of the data section wrapped to a
signed 32-bit int. The archive is fine: asar list and asar extract-file both
work on it, since neither goes through that read.

Cause

extractAll() reads the entire data section in one call:

const dataSize = archiveSize - dataStart;
dataBuf = Buffer.alloc(dataSize);
fs.readSync(fd, dataBuf, 0, dataSize, dataStart);

fs.readSync() truncates length to a signed 32-bit integer, so a dataSize
of 2 GiB or more arrives negative and validateOffsetLengthRead() rejects it.
Buffer.alloc() is fine on 64-bit Node, so the failure is entirely in the read.

Introduced in #414. Affected: 4.1.2, 4.2.0, 4.2.1 and current main. 4.1.1 and
earlier are unaffected.

Fix

The descriptor stays open for the whole extraction, which is where the gain over
re-opening per file comes from, but each entry is copied to disk in bounded
64 MiB chunks rather than buffering the whole archive first. Peak memory is now
flat instead of proportional to archive size.

Measured on a 3.9 GB archive with 1636 entries:

before after
result ERR_OUT_OF_RANGE 4.2 s
peak memory ~3.7 GB 201 MB

I verified the output byte for byte against asar extract-file run over every
entry in the archive.

Two smaller things came out of the same loop:

  • The copy loop respects the fs.readSync() return value, which is allowed to
    be shorter than requested. The single-shot read ignored it.
  • Unpacked entries go through ensureWithin(), which is what readFileSync()
    in disk.ts already does for the same input. That is a hardening rather than
    part of the reported bug, so it is a separate commit and can be dropped
    without touching the fix.

Tests

extractFileWithFd() takes an optional chunk size, so the multi-chunk path is
covered without materialising a 2 GiB fixture. Four cases in disk-spec.ts:
multi-chunk copy, single-chunk copy, zero-length entry, and an archive that ends
early.

198 tests pass under both node:fs and Electron's original-fs, plus tsc over
src and test, oxlint and oxfmt. I could not test Windows locally, where
followLinks takes the other branch, but that logic is untouched.

Unrelated, but

#414 is also where #446 comes from, and that one is still open.

extractAll() read the whole data section into a single buffer with one
fs.readSync() call. fs.readSync() truncates its length argument to a signed
32-bit integer, so any archive whose data section reaches 2 GiB arrives as a
negative length and fails before a single file is written:

    RangeError [ERR_OUT_OF_RANGE]: The value of "length" is out of range.
    It must be >= 0. Received -348492367
        at Object.readSync (node:fs:726:3)
        at extractAll (.../@electron/asar/lib/asar.js:245:16)

asar list and asar extract-file work on the same archives, since neither goes
through that read.

I kept the descriptor open for the whole extraction, which is where the gain
over re-opening per file actually comes from, but each entry is now copied to
disk in bounded chunks instead of buffering the entire archive. Peak memory no
longer scales with archive size: a 3.9 GB archive goes from failing outright to
4.2s at 201 MB peak.

The copy loop also respects the fs.readSync() return value, which is allowed to
be shorter than requested. The single-shot read ignored it.

extractFileWithFd() takes an optional chunk size so the multi-chunk path can be
covered without materialising a 2 GiB fixture.
readFileSync() in disk.ts already resolves unpacked entries through
ensureWithin(). extractAll() joined the path directly instead, so the two
disagreed on the same input.

This is separate from the 2 GiB read fix and can be dropped on its own.
@jmrplens
jmrplens requested a review from a team as a code owner August 7, 2026 11:14
@MarshallOfSound

Copy link
Copy Markdown
Member

Can you we not do this with read streams instead of custom chunk logic, a pipeline with readstreams should be much better

@jmrplens

jmrplens commented Oct 7, 2026

Copy link
Copy Markdown
Author

Can you we not do this with read streams instead of custom chunk logic, a pipeline with readstreams should be much better

I tried it before answering, because a pipeline was my first idea too. It does fix the 2 GiB failure, but I'd still keep the synchronous copy, for two reasons.

1. extractAll() would have to become async. There's no synchronous stream pipeline, so moving to streams changes the return type of a public function to a Promise. Existing callers don't await it: @electron/universal, which depends on @electron/asar ^4.0.0, calls asar.extractAll() twice and reads the extracted files right after (src/asar-utils.ts#L203-L206). An async extractAll() in a 4.x release would leave it working on half-extracted directories without any error. The bad-symlink spec, which expects extractAll() to throw synchronously, would need rewriting as well.

2. It isn't better in any case I measured. I put the stream version next to main and this PR, with fs.createReadStream({ fd, start, end, autoClose: false }) piped into fs.createWriteStream() through stream/promises' pipeline(), sharing one descriptor for the whole extraction, as this PR does:

Archive main This PR (64 MiB chunk) This PR (1 MiB chunk) Streams (default 64 KiB highWaterMark) Streams (1 MiB highWaterMark)
20,000 files of 1 to 16 KiB (179 MB) 0.9 to 1.8 s, 270 MB 1.0 s, 156 MB 0.95 s, 156 MB 2.8 to 3.1 s, 113 MB 3.0 s, 113 MB
3 × 1.25 GiB + 500 small files (4.03 GB) ERR_OUT_OF_RANGE 3.7 to 8.5 s, 244 MB 4.4 to 6.4 s, 62 MB 5.6 to 8.3 s, 108 MB 4.0 to 11.4 s, 131 MB

Times are wall clock and memory is peak RSS, on Linux with Node 25.2.1 and a warm page cache; where there's a range, it's the spread over two or three runs. Every run's output was compared with the source tree using diff -r, and all of them were identical.

  • Many small files, which is what most app archives look like, are about three times slower with streams. Each entry pays for its own async pipeline, which gives back the extraction speedup from perf: 5-7x faster packing, 15-20% faster extraction #414.
  • The large archive is bound by disk writes. The spread between runs is bigger than the difference between approaches, so streams are no faster there.
  • Memory is the only place streams beat this PR as it stands, and that's because of the 64 MiB chunk. With a 1 MiB chunk, the synchronous copy peaks at 62 MB on the 4 GB archive, below either stream variant, at the same speed. I'm happy to make that change here.

So if streams are still preferred, that route means a breaking API change, or a separate async function next to the synchronous one, and it would still be slower with many small files and use more memory than the chunked copy. If an async extraction API is wanted for its own sake, I'd rather do it as a follow-up and keep this PR as the 2 GiB fix for 4.x.

The chunk loop itself is the part I'd most like to keep small. It's one buffer, one readSync and one writeSync per iteration, and it advances by what readSync actually returned, which the old single read didn't do.

@MarshallOfSound

Copy link
Copy Markdown
Member

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I also checked whether asarUnpack'd entries get the same flat-memory treatment as packed entries — they still go through fs.readFileSync (src/asar.ts:411), which is unchanged from before this PR and outside this fix's stated scope, not a regression it introduces.

Extended reasoning...

The change reworks asar extraction (src/asar.ts, src/disk.ts, test/disk-spec.ts) to stream packed entries from an open fd in bounded chunks instead of buffering the whole data section, fixing a real ERR_OUT_OF_RANGE bug on archives >= 2 GiB; it also adds offset validation and runs unpacked-file reads through ensureWithin for path-traversal hardening. The security-sensitive surface is path handling (ensureWithin) and file I/O correctness, not auth/crypto. A confirmed inline finding (unchecked fs.writeSync return value in extractFileWithFd, src/disk.ts:440) can cause silent truncation on short writes, which is enough on its own to withhold approval regardless of this note.

Comment thread src/disk.ts Outdated
Comment on lines +419 to +440
destPath: string,
position: number,
size: number,
chunkSize: number = EXTRACT_CHUNK_SIZE,
) {
const out = fs.openSync(destPath, 'w');
try {
if (size <= 0) {
return;
}
const buffer = Buffer.alloc(Math.min(size, chunkSize));
let copied = 0;
while (copied < size) {
const wanted = Math.min(buffer.length, size - copied);
// `readSync` is allowed to return fewer bytes than requested, so always loop on the result.
const read = fs.readSync(fd, buffer, 0, wanted, position + copied);
if (read <= 0) {
throw new Error(
`Unexpected end of archive while extracting "${destPath}" (read ${copied} of ${size} bytes)`,
);
}
fs.writeSync(out, buffer, 0, read);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 extractFileWithFd can silently write truncated/corrupted files on a short fs.writeSync, something the base branch's fs.writeFileSync never allowed. The loop checks fs.readSync's return value (line 434) but calls fs.writeSync(out, buffer, 0, read) at line 440 without checking how many bytes were actually written, then advances copied by the full read count regardless. Node's own fs.writeFileSync internally loops on writeSync's return exactly because a single call isn't guaranteed to write the whole buffer; this streaming path dropped that safeguard. Fix: loop writeSync like readSync is looped, accumulating bytes actually written and retrying the remainder before advancing copied/position, so partial writes on slow, quota-limited, or network-backed destinations can't silently shrink or corrupt extracted file contents.

Why this was flagged

Trigger: extracting any packed entry via extractAll() -> extractFileWithFd() (src/asar.ts new call site) when the destination filesystem's write(2) returns fewer bytes than requested for a chunk (NFS/CIFS mounts, disk-quota edge, EINTR not retried, slow device) — rare on local ext4 but real on network or constrained storage, which asar extract has no control over. At src/disk.ts:440 the code writes read bytes but ignores the return value and advances copied += read unconditionally, not by bytes actually written. The output file loses the unwritten tail of that chunk, and the next chunk's source bytes are appended right after via the file's current write offset, producing a shorter, corrupted file with no error reported — extraction appears to succeed. On base, this same code path called fs.writeFileSync, which loops internally until every byte is written, so this failure mode did not exist there.

Verification: normal (regression from base). src/disk.ts:440-441: fs.writeSync(out, buffer, 0, read); copied += read; — the write count is discarded and copied advances by bytes read, not bytes written. fs.writeSync() issues a single write(2) and may return fewer bytes than requested; it does not loop. On a short write the unwritten tail is lost and the destination file ends up silently truncated/corrupted with no error (the only guard, line 434, checks the read side, not the write side).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5e24ced: each chunk is now written in a loop that advances the buffer offset and the file position by what fs.writeSync() actually returned, and a write that makes no progress throws instead of looping. The new keeps writing when writeSync writes fewer bytes than it was given test in test/disk-spec.ts caps every writeSync call at 3 bytes and checks the extracted file is complete and byte-identical (before the fix it came out 19 of 43 bytes, with no error).

extractFileWithFd() looped on fs.readSync() but wrote each chunk with a single
fs.writeSync() call and ignored its return value. writeSync() is allowed to
write fewer bytes than it is given, and when it did, the rest of the chunk was
dropped and the next chunk was appended right after it, so the extracted file
came out short and corrupted with no error. The fs.writeFileSync() call that
extraction used before loops on writeSync() internally, which is why this could
not happen there.

Each chunk is now written in a loop that advances the buffer offset and the
file position by what writeSync() actually wrote, and a write that makes no
progress throws instead of looping forever.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants