From c2d1953648730b5e6b9b81dead3a1dbd6ba5abbb Mon Sep 17 00:00:00 2001 From: Alec Gibson <12036746+alecgibson@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:37:10 +0000 Subject: [PATCH] fix #96: closing an unreadable archive leaks a file handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/antelle/node-stream-zip/issues/96 Fixes https://github.com/antelle/node-stream-zip/issues/94 1. `new StreamZip.async({file: 'unreadable.zip'})` 2. `await zip.entries()` rejects with `Bad archive` 3. `await zip.close()` in a `finally` rejects too, and the file handle is never released `close()` began by awaiting the readiness promise, which rejects for an archive that never opens, so the underlying `close()` was never reached. Holding the `StreamZip` instance directly means teardown no longer depends on the archive parsing. The `await` stays because the underlying `close()` treats an unset `fd` as nothing to do, so racing a pending open would leak the descriptor that open is about to produce. We swallow its rejection so that `close()` matches the callback API, where `zip.close(cb)` after an `error` event calls back with no error, and so a `close()` in a `finally` stops masking the real error. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- node_stream_zip.js | 6 ++++-- release-notes.md | 4 ++++ test/tests.js | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/node_stream_zip.js b/node_stream_zip.js index ce5667e..0a9af79 100644 --- a/node_stream_zip.js +++ b/node_stream_zip.js @@ -667,6 +667,7 @@ StreamZip.debugLog = (...args) => { util.inherits(StreamZip, events.EventEmitter); const propZip = Symbol('zip'); +const propZipInstance = Symbol('zipInstance'); StreamZip.async = class StreamZipAsync extends events.EventEmitter { constructor(config) { @@ -677,6 +678,7 @@ StreamZip.async = class StreamZipAsync extends events.EventEmitter { zip.on('entry', (entry) => this.emit('entry', entry)); zip.on('extract', (entry, outPath) => this.emit('extract', entry, outPath)); + this[propZipInstance] = zip; this[propZip] = new Promise((resolve, reject) => { zip.on('ready', () => { zip.removeListener('error', reject); @@ -746,9 +748,9 @@ StreamZip.async = class StreamZipAsync extends events.EventEmitter { } async close() { - const zip = await this[propZip]; + await this[propZip].catch(() => {}); return new Promise((resolve, reject) => { - zip.close((err) => { + this[propZipInstance].close((err) => { if (err) { reject(err); } else { diff --git a/release-notes.md b/release-notes.md index 9bb3b10..d2e5df6 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,5 +1,9 @@ Release notes ------------- +##### v1.16.1 (2026-09-15) +`-` fix #96, #94: file handle leak in the async API when the archive could not be read +`*` async `close()` now resolves instead of rejecting when the archive could not be read, matching the callback API + ##### v1.16.0 (2026-07-22) `+` fixed reading compressed entry size to prevent exploitation reported by @DumDumSu `*` updated dependencies diff --git a/test/tests.js b/test/tests.js index a814b2e..97bc071 100644 --- a/test/tests.js +++ b/test/tests.js @@ -485,6 +485,32 @@ module.exports['async.entryData'] = async (test) => { test.done(); }; +module.exports['async.close'] = async (test) => { + test.expect(2); + const fd = fs.openSync('test/ok/normal.zip', 'r'); + const zip = new StreamZip.async({ fd }); + test.equal(await zip.entriesCount, 10); + await zip.close(); + test.throws(() => fs.fstatSync(fd), /EBADF/); + test.done(); +}; + +module.exports['async.close on a bad archive'] = async (test) => { + test.expect(3); + const fd = fs.openSync('test/err/rar.rar', 'r'); + const zip = new StreamZip.async({ fd }); + try { + await zip.entries(); + test.ok(false, 'Should throw an error'); + } catch (e) { + test.equal(e.message, 'Bad archive'); + } + test.ok(fs.fstatSync(fd)); + await zip.close(); + test.throws(() => fs.fstatSync(fd), /EBADF/); + test.done(); +}; + module.exports.setUp = function (done) { testPathTmp = basePathTmp + testNum++ + '/'; if (!fs.existsSync(basePathTmp)) {