From 111a62d9b8ed0e01e9d2ff928ba5b686801899c2 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:14:23 +0800 Subject: [PATCH 01/22] fs: key glob matcher cache by platform Glob matchers capture the path platform when they are created. Include the platform in the cache key so path.posix and path.win32 do not share incompatible matchers for the same pattern. Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64571 Fixes: https://github.com/nodejs/node/issues/64570 Reviewed-By: James M Snell Reviewed-By: Aviv Keller Reviewed-By: Moshe Atlow --- lib/internal/fs/glob.js | 7 ++++--- test/parallel/test-path-glob.js | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/internal/fs/glob.js b/lib/internal/fs/glob.js index a0e7837db7c3..c608016833f9 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -936,15 +936,16 @@ function matchGlobPattern(path, pattern, windows = isWindows) { validateString(path, 'path'); validateString(pattern, 'pattern'); + const cacheKey = `${windows ? 'win32' : 'posix'}:${pattern}`; let matcher; - if (patternFnCache.has(pattern)) { - matcher = patternFnCache.get(pattern); + if (patternFnCache.has(cacheKey)) { + matcher = patternFnCache.get(cacheKey); } else { matcher = createMatcher(pattern, { kEmptyObject, platform: windows ? 'win32' : 'posix', }); - patternFnCache.set(pattern, matcher); + patternFnCache.set(cacheKey, matcher); if (patternFnCache.size >= kGlobPatternCacheLimit) { patternFnCache.delete(patternFnCache.keys().next().value); diff --git a/test/parallel/test-path-glob.js b/test/parallel/test-path-glob.js index 47647e12784e..11633f52245e 100644 --- a/test/parallel/test-path-glob.js +++ b/test/parallel/test-path-glob.js @@ -31,6 +31,9 @@ const globs = { ], }; +// Matchers for the same pattern must not be shared across path platforms. +assert.strictEqual(path.posix.matchesGlob('platform-cache/file', 'platform-cache/**'), true); +assert.strictEqual(path.win32.matchesGlob('platform-cache\\file', 'platform-cache/**'), true); for (const [platform, platformGlobs] of Object.entries(globs)) { for (const [pathStr, glob, expected] of platformGlobs) { From f9e2fe059734f130717910ddd95d52c9f20c2762 Mon Sep 17 00:00:00 2001 From: Archkon <180910180+Archkon@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:14:38 +0800 Subject: [PATCH 02/22] wasm: register missing SetURL function Signed-off-by: Archkon <180910180+Archkon@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64679 Reviewed-By: Aviv Keller Reviewed-By: Guy Bedford --- src/node_wasm_web_api.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node_wasm_web_api.cc b/src/node_wasm_web_api.cc index a1e52726f3a7..a49cab7fb620 100644 --- a/src/node_wasm_web_api.cc +++ b/src/node_wasm_web_api.cc @@ -44,6 +44,7 @@ Local WasmStreamingObject::Initialize(Environment* env) { void WasmStreamingObject::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(New); + registry->Register(SetURL); registry->Register(Push); registry->Register(Finish); registry->Register(Abort); From f85fdabc4780dc4742490c8c4728fac707480ba8 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:57:47 -0700 Subject: [PATCH 03/22] ffi: fix optimized buffer conversions Preserve pointer-like argument conversions for buffer and arraybuffer signatures after Fast API optimization. Keep memory-backed arguments on the specialized native fast path. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64639 Fixes: https://github.com/nodejs/node/issues/64638 Reviewed-By: Paolo Insogna Reviewed-By: Aviv Keller --- lib/ffi.js | 2 - lib/internal/ffi/fast-api.js | 65 ++++++++------------------------ src/ffi/fast.cc | 22 ++++++++++- src/ffi/fast.h | 2 + src/node_ffi.cc | 3 +- test/ffi/test-ffi-fast-buffer.js | 38 +++++++++++++++++++ 6 files changed, 79 insertions(+), 53 deletions(-) diff --git a/lib/ffi.js b/lib/ffi.js index f35ebae3e312..01c330953ac8 100644 --- a/lib/ffi.js +++ b/lib/ffi.js @@ -68,7 +68,6 @@ const { } = require('internal/ffi-shared-buffer'); const { - initializeFastBufferMetadata, wrapWithRawPointerConversions, } = require('internal/ffi/fast-api'); @@ -90,7 +89,6 @@ function wrapFFIFunction(rawFn, owner) { returnType = rawFn[kSbReturn]; } } - initializeFastBufferMetadata(rawFn, argumentTypes); const wrapped = wrapWithSharedBuffer( rawFn, argumentTypes === undefined ? undefined : makeSignature(argumentTypes, returnType)); diff --git a/lib/internal/ffi/fast-api.js b/lib/internal/ffi/fast-api.js index c897f1baf9e6..497304f59a36 100644 --- a/lib/internal/ffi/fast-api.js +++ b/lib/internal/ffi/fast-api.js @@ -6,7 +6,6 @@ const { ObjectDefineProperty, ReflectApply, StringPrototypeIncludes, - Symbol, TypeError, } = primordials; @@ -24,11 +23,8 @@ const { getRawPointer, kFastArguments, kFastBufferInvoke, - kSbSharedBuffer, } = internalBinding('ffi'); -const kFastBuffer = Symbol('kFastBuffer'); - const U64_MAX = 0xFFFFFFFFFFFFFFFFn; const I64_MAX = 0x7FFFFFFFFFFFFFFFn; const I64_MIN = -0x8000000000000000n; @@ -79,16 +75,13 @@ function validateFastIntegerArg(type, value, index) { } } -function needsRawPointerConversion(type, rawFn) { - if (rawFn !== undefined && rawFn[kFastBuffer] === true && - (type === 'buffer' || type === 'arraybuffer')) { - return false; - } +function needsRawPointerConversion(type) { return type === 'buffer' || type === 'arraybuffer'; } function needsPointerLikeConversion(type) { - return type === 'pointer' || type === 'ptr' || type === 'function'; + return type === 'pointer' || type === 'ptr' || type === 'function' || + type === 'buffer' || type === 'arraybuffer'; } function needsStringPointerConversion(type) { @@ -100,12 +93,8 @@ function needsNullPointerConversion(type) { needsRawPointerConversion(type); } -function needsPointerConversion(type, rawFn) { - if (rawFn !== undefined && rawFn[kFastBuffer] === true && - (type === 'buffer' || type === 'arraybuffer')) { - return false; - } - return needsRawPointerConversion(type, rawFn) || +function needsPointerConversion(type) { + return needsRawPointerConversion(type) || needsNullPointerConversion(type) || needsStringPointerConversion(type); } @@ -174,11 +163,11 @@ function convertPointerArg(type, value, stringState, index) { return value; } -function getFastArgumentIndexes(argumentsTypes, rawFn) { +function getFastArgumentIndexes(argumentsTypes) { let indexes = null; for (let i = 0; i < argumentsTypes.length; i++) { if (fastIntegerTypeInfo[argumentsTypes[i]] === undefined && - !needsPointerConversion(argumentsTypes[i], rawFn)) { + !needsPointerConversion(argumentsTypes[i])) { continue; } if (indexes === null) { @@ -189,31 +178,12 @@ function getFastArgumentIndexes(argumentsTypes, rawFn) { return indexes; } -function convertFastArg(type, value, rawFn, stringState, index) { +function convertFastArg(type, value, stringState, index) { validateFastIntegerArg(type, value, index); - return needsPointerConversion(type, rawFn) ? + return needsPointerConversion(type) ? convertPointerArg(type, value, stringState, index) : value; } -function initializeFastBufferMetadata(rawFn, argumentTypes) { - if (rawFn === undefined || rawFn === null || argumentTypes === undefined) { - return; - } - if (rawFn[kSbSharedBuffer] !== undefined) { - return; - } - - if (rawFn[kFastArguments] !== undefined) { - for (let i = 0; i < argumentTypes.length; i++) { - const type = argumentTypes[i]; - if (type === 'buffer' || type === 'arraybuffer') { - rawFn[kFastBuffer] = true; - break; - } - } - } -} - function inheritMetadata(wrapper, rawFn, nargs) { ObjectDefineProperty(wrapper, 'name', { __proto__: null, value: rawFn.name, configurable: true, @@ -239,7 +209,7 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { return rawFn; } - const indexes = getFastArgumentIndexes(argumentTypes, rawFn); + const indexes = getFastArgumentIndexes(argumentTypes); if (indexes === null) { return rawFn; } @@ -295,9 +265,8 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { (c1 && hasStringPointerArg(t1, a1)); if (stringCall) enterStringConversion(stringState); try { - return rawFn(c0 ? - convertFastArg(t0, a0, rawFn, stringState, 0) : a0, - c1 ? convertFastArg(t1, a1, rawFn, stringState, 1) : a1); + return rawFn(c0 ? convertFastArg(t0, a0, stringState, 0) : a0, + c1 ? convertFastArg(t1, a1, stringState, 1) : a1); } finally { if (stringCall) exitStringConversion(stringState); } @@ -318,10 +287,9 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { (c2 && hasStringPointerArg(t2, a2)); if (stringCall) enterStringConversion(stringState); try { - return rawFn(c0 ? - convertFastArg(t0, a0, rawFn, stringState, 0) : a0, - c1 ? convertFastArg(t1, a1, rawFn, stringState, 1) : a1, - c2 ? convertFastArg(t2, a2, rawFn, stringState, 2) : a2); + return rawFn(c0 ? convertFastArg(t0, a0, stringState, 0) : a0, + c1 ? convertFastArg(t1, a1, stringState, 1) : a1, + c2 ? convertFastArg(t2, a2, stringState, 2) : a2); } finally { if (stringCall) exitStringConversion(stringState); } @@ -344,7 +312,7 @@ function wrapWithRawPointerConversions(rawFn, argumentTypes, _owner) { for (let i = 0; i < indexes.length; i++) { const index = indexes[i]; args[index] = convertFastArg( - argumentTypes[index], args[index], rawFn, stringState, index); + argumentTypes[index], args[index], stringState, index); } return ReflectApply(rawFn, undefined, args); } finally { @@ -360,6 +328,5 @@ module.exports = { convertPointerArg, hasPointerMemoryArg, hasStringPointerArg, - initializeFastBufferMetadata, wrapWithRawPointerConversions, }; diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc index 8c4420761fec..cb753993c677 100644 --- a/src/ffi/fast.cc +++ b/src/ffi/fast.cc @@ -178,12 +178,32 @@ bool IsPointerTypeName(const std::string& name) { return name == "pointer" || name == "ptr" || name == "function"; } +bool IsBufferTypeName(const std::string& name) { + return name == "buffer" || name == "arraybuffer"; +} + bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn) { // The secondary buffer invoke is only generated for the hot monomorphic case // where a single pointer-like argument can be satisfied by a Buffer or // ArrayBuffer without allocating or caching a BigInt pointer in JS. return fn.arg_type_names.size() == 1 && - IsPointerTypeName(fn.arg_type_names[0]); + (IsPointerTypeName(fn.arg_type_names[0]) || + IsBufferTypeName(fn.arg_type_names[0])); +} + +std::shared_ptr CloneWithRawPointerArgNames( + const std::shared_ptr& fn) { + // The primary Fast API entrypoint receives pointer-compatible values as + // BigInts after the JS wrapper has converted strings, nullish values, and + // memory-backed objects. A secondary entrypoint handles the monomorphic + // memory-backed case without extracting the pointer in JS. + auto clone = std::make_shared(*fn); + for (std::string& name : clone->arg_type_names) { + if (IsBufferTypeName(name)) { + name = "pointer"; + } + } + return clone; } std::shared_ptr CloneWithFastBufferArgNames( diff --git a/src/ffi/fast.h b/src/ffi/fast.h index 539df26da79b..b85191aade13 100644 --- a/src/ffi/fast.h +++ b/src/ffi/fast.h @@ -61,6 +61,8 @@ bool SignatureNeedsRawPointerConversions(const FFIFunction& fn); bool SignatureNeedsFastIntegerValidation(const FFIFunction& fn); bool IsPointerTypeName(const std::string& name); bool SignatureNeedsFastBufferInvoke(const FFIFunction& fn); +std::shared_ptr CloneWithRawPointerArgNames( + const std::shared_ptr& fn); std::shared_ptr CloneWithFastBufferArgNames( const std::shared_ptr& fn); std::unique_ptr CreateFastFFIMetadata(const FFIFunction& fn); diff --git a/src/node_ffi.cc b/src/node_ffi.cc index 23c58e8ea128..b05d09270126 100644 --- a/src/node_ffi.cc +++ b/src/node_ffi.cc @@ -249,7 +249,8 @@ MaybeLocal DynamicLibrary::CreateFunction( // Try the generated Fast API path first. If metadata creation rejects the // signature, fall back to SharedBuffer for supported scalar shapes, then to // the generic libffi invoker. - info->fast_metadata = CreateFastFFIMetadata(*fn); + std::shared_ptr fast_fn = CloneWithRawPointerArgNames(fn); + info->fast_metadata = CreateFastFFIMetadata(*fast_fn); bool use_fast_api = info->fast_metadata != nullptr; bool use_sb = !use_fast_api && IsSBEligibleSignature(*fn); bool has_ptr_args = use_sb && SignatureHasPointerArgs(*fn); diff --git a/test/ffi/test-ffi-fast-buffer.js b/test/ffi/test-ffi-fast-buffer.js index 596ae83d1888..97d8a3c9b852 100644 --- a/test/ffi/test-ffi-fast-buffer.js +++ b/test/ffi/test-ffi-fast-buffer.js @@ -95,3 +95,41 @@ test('fast FFI string buffers survive reentrant callbacks', { lib.close(); } }); + +test('optimized buffer signatures preserve pointer-like conversions', () => { + const lib = new ffi.DynamicLibrary(libraryPath); + const asBuffer = lib.getFunction('pointer_to_usize', { + arguments: ['buffer'], + return: 'u64', + }); + const asArrayBuffer = lib.getFunction('pointer_to_usize', { + arguments: ['arraybuffer'], + return: 'u64', + }); + + function callBuffer(value) { + return asBuffer(value); + } + + function callArrayBuffer(value) { + return asArrayBuffer(value); + } + + try { + for (let i = 0; i < 100_000; i++) { + assert.strictEqual(callBuffer(0n), 0n); + assert.strictEqual(callArrayBuffer(0n), 0n); + } + + for (const call of [callBuffer, callArrayBuffer]) { + assert.strictEqual(call(null), 0n); + assert.strictEqual(call(undefined), 0n); + assert.notStrictEqual(call('ffi'), 0n); + + const bytes = Buffer.alloc(1); + assert.strictEqual(call(bytes), ffi.getRawPointer(bytes)); + } + } finally { + lib.close(); + } +}); From c8e2a824002ea6ca1a4e0d6ee465a9ca399cc0c7 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:57:57 -0700 Subject: [PATCH 04/22] ffi: validate fast 32-bit integer argument ranges Add i32, int32, u32, and uint32 to Fast API integer validation so optimized calls reject invalid values instead of allowing V8 to coerce or truncate them. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: openai:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64691 Fixes: https://github.com/nodejs/node/issues/64690 Reviewed-By: Aviv Keller Reviewed-By: Paolo Insogna --- lib/internal/ffi/fast-api.js | 4 ++++ src/ffi/fast.cc | 3 ++- test/ffi/test-ffi-fast-integer-validation.js | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/internal/ffi/fast-api.js b/lib/internal/ffi/fast-api.js index 497304f59a36..ebfaed92b27d 100644 --- a/lib/internal/ffi/fast-api.js +++ b/lib/internal/ffi/fast-api.js @@ -46,6 +46,10 @@ const fastIntegerTypeInfo = { int16: { kind: 'number', min: -32768, max: 32767, label: 'an int16' }, u16: { kind: 'number', min: 0, max: 65535, label: 'a uint16' }, uint16: { kind: 'number', min: 0, max: 65535, label: 'a uint16' }, + i32: { kind: 'number', min: -2147483648, max: 2147483647, label: 'an int32' }, + int32: { kind: 'number', min: -2147483648, max: 2147483647, label: 'an int32' }, + u32: { kind: 'number', min: 0, max: 4294967295, label: 'a uint32' }, + uint32: { kind: 'number', min: 0, max: 4294967295, label: 'a uint32' }, i64: { kind: 'bigint', min: I64_MIN, max: I64_MAX, label: 'an int64' }, int64: { kind: 'bigint', min: I64_MIN, max: I64_MAX, label: 'an int64' }, u64: { kind: 'bigint', min: 0n, max: U64_MAX, label: 'a uint64' }, diff --git a/src/ffi/fast.cc b/src/ffi/fast.cc index cb753993c677..ea98bf8aa4f1 100644 --- a/src/ffi/fast.cc +++ b/src/ffi/fast.cc @@ -164,7 +164,8 @@ bool SignatureNeedsFastIntegerValidation(const FFIFunction& fn) { for (const std::string& name : fn.arg_type_names) { if (name == "bool" || name == "char" || name == "i8" || name == "int8" || name == "u8" || name == "uint8" || name == "i16" || name == "int16" || - name == "u16" || name == "uint16" || name == "i64" || name == "int64" || + name == "u16" || name == "uint16" || name == "i32" || name == "int32" || + name == "u32" || name == "uint32" || name == "i64" || name == "int64" || name == "u64" || name == "uint64") { return true; } diff --git a/test/ffi/test-ffi-fast-integer-validation.js b/test/ffi/test-ffi-fast-integer-validation.js index 49fd1364948c..26d51ae4248f 100644 --- a/test/ffi/test-ffi-fast-integer-validation.js +++ b/test/ffi/test-ffi-fast-integer-validation.js @@ -28,6 +28,10 @@ test('fast FFI validates integer argument ranges', () => { function callU16(value) { return functions.add_u16(value, 0); } + function callI32(value) { return functions.add_i32(value, 0); } + + function callU32(value) { return functions.add_u32(value, 0); } + function callI64(value) { return functions.add_i64(value, 0n); } function callU64(value) { return functions.add_u64(value, 0n); } @@ -37,6 +41,8 @@ test('fast FFI validates integer argument ranges', () => { [callU8, 0], [callI16, 0], [callU16, 0], + [callI32, 0], + [callU32, 0], [callI64, 0n], [callU64, 0n], ]) { @@ -48,6 +54,14 @@ test('fast FFI validates integer argument ranges', () => { assert.throws(() => callU8(256), expect); assert.throws(() => callI16(32768), expect); assert.throws(() => callU16(65536), expect); + assert.throws(() => callI32(2147483648), expect); + assert.throws(() => callI32(-2147483649), expect); + assert.throws(() => callI32(1.5), expect); + assert.throws(() => callI32('1'), expect); + assert.throws(() => callU32(4294967296), expect); + assert.throws(() => callU32(-1), expect); + assert.throws(() => callU32(1.5), expect); + assert.throws(() => callU32('1'), expect); assert.throws(() => callI64(2n ** 63n), expect); assert.throws(() => callU64(2n ** 64n), expect); } finally { From a58aad785b69725210068b43c9bacca7abe0399a Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:11:16 -0700 Subject: [PATCH 05/22] sqlite: check database state before calling SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check whether the database is open in enableLoadExtension() and setAuthorizer() before passing the connection to SQLite. This prevents calls after close() from terminating the process and makes them throw ERR_INVALID_STATE instead. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64812 Fixes: https://github.com/nodejs/node/issues/64811 Reviewed-By: Edy Silva Reviewed-By: René --- src/node_sqlite.cc | 9 +++++++-- test/parallel/test-sqlite-authz.js | 12 ++++++++++++ test/parallel/test-sqlite-config.js | 12 ++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 282184ddcf02..8c3709178af0 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -2396,7 +2396,10 @@ void DatabaseSync::EnableLoadExtension( const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); - auto isolate = args.GetIsolate(); + Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + + Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { THROW_ERR_INVALID_ARG_TYPE(isolate, "The \"allow\" argument must be a boolean."); @@ -2424,7 +2427,7 @@ void DatabaseSync::EnableDefensive(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); - auto isolate = args.GetIsolate(); + Isolate* isolate = env->isolate(); if (!args[0]->IsBoolean()) { THROW_ERR_INVALID_ARG_TYPE(isolate, "The \"active\" argument must be a boolean."); @@ -2475,6 +2478,8 @@ void DatabaseSync::SetAuthorizer(const FunctionCallbackInfo& args) { DatabaseSync* db; ASSIGN_OR_RETURN_UNWRAP(&db, args.This()); Environment* env = Environment::GetCurrent(args); + THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + Isolate* isolate = env->isolate(); if (args[0]->IsNull()) { diff --git a/test/parallel/test-sqlite-authz.js b/test/parallel/test-sqlite-authz.js index 2bf268847cd3..69c075a57e2e 100644 --- a/test/parallel/test-sqlite-authz.js +++ b/test/parallel/test-sqlite-authz.js @@ -275,4 +275,16 @@ suite('DatabaseSync.prototype.setAuthorizer()', () => { message: /The "callback" argument must be a function/ }); }); + + it('throws if database is not open', () => { + const db = new DatabaseSync(':memory:'); + db.close(); + + assert.throws(() => { + db.setAuthorizer(() => constants.SQLITE_OK); + }, { + code: 'ERR_INVALID_STATE', + message: 'database is not open', + }); + }); }); diff --git a/test/parallel/test-sqlite-config.js b/test/parallel/test-sqlite-config.js index 411b9d1cfaea..d1a6cae2d744 100644 --- a/test/parallel/test-sqlite-config.js +++ b/test/parallel/test-sqlite-config.js @@ -61,3 +61,15 @@ test('throws if options.defensive is provided but is not a boolean', (t) => { message: 'The "options.defensive" argument must be a boolean.', }); }); + +test('enableLoadExtension() throws if database is not open', (t) => { + const db = new DatabaseSync(':memory:', { allowExtension: true }); + db.close(); + + t.assert.throws(() => { + db.enableLoadExtension(false); + }, { + code: 'ERR_INVALID_STATE', + message: 'database is not open', + }); +}); From bec3d0b3cadefafc1cae7e2fcbf4d0f337402ba8 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:11:37 -0700 Subject: [PATCH 06/22] vfs: speed up recursive readdir test setup Create the deep directory tree with one recursive mkdirSync() call. This avoids repeatedly resolving increasingly long paths and prevents timeouts on slower CI hosts. Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com> Assisted-by: codex:gpt-5.6-sol PR-URL: https://github.com/nodejs/node/pull/64813 Refs: https://github.com/nodejs/reliability/issues?q=sort%3Aupdated-desc%20%22test-vfs-readdir-symlink-recursive%22 Reviewed-By: Aviv Keller Reviewed-By: Tim Perry --- test/parallel/test-vfs-readdir-symlink-recursive.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js index f46a12da7b9a..ce0538ec1ca2 100644 --- a/test/parallel/test-vfs-readdir-symlink-recursive.js +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -114,12 +114,8 @@ assert.ok( const v = vfs.create(); // Build /deep/0/1/2/.../999/leaf.txt - let path = '/deep'; - v.mkdirSync(path); - for (let i = 0; i < DEPTH; i++) { - path += `/${i}`; - v.mkdirSync(path); - } + const path = `/deep/${Array.from({ length: DEPTH }, (_, i) => i).join('/')}`; + v.mkdirSync(path, { recursive: true }); v.writeFileSync(`${path}/leaf.txt`, 'deep'); const entries = v.readdirSync('/deep', { recursive: true }); From 598693b3a2f38cc4eea78bb50df6109d9d366d23 Mon Sep 17 00:00:00 2001 From: Soul Lee Date: Fri, 31 Jul 2026 13:11:51 +0900 Subject: [PATCH 07/22] doc: remove obsolete cctest node.gyp instructions Since 6aa42f9cf04, configure.py collects every .cc and .h file under test/cctest into the node_cctest_sources variable, so a new C++ unit test no longer has to be listed in the sources of the cctest target. Document the feature-gated source lists that still need to be updated by hand instead. Refs: https://github.com/nodejs/node/pull/56885 Signed-off-by: Soul Lee PR-URL: https://github.com/nodejs/node/pull/64814 Refs: https://github.com/nodejs/node/pull/56885 Reviewed-By: Luigi Pinca Reviewed-By: Matteo Collina --- doc/contributing/writing-tests.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/doc/contributing/writing-tests.md b/doc/contributing/writing-tests.md index 3f439d6d70b5..f9ca3c772017 100644 --- a/doc/contributing/writing-tests.md +++ b/doc/contributing/writing-tests.md @@ -430,19 +430,21 @@ static void at_exit_callback(void* arg) { } ``` -Next add the test to the `sources` in the `cctest` target in node.gyp: +There is no need to list the file anywhere: `configure.py` collects every `.cc` +and `.h` file under `test/cctest` into the `node_cctest_sources` variable that +the `cctest` target in node.gyp builds. -```console -'sources': [ - 'test/cctest/test_env.cc', - ... -], -``` +If the test can only be built when a given feature is enabled, add it to the +matching variable in node.gyp so that it is excluded from the build otherwise: + +* `node_cctest_openssl_sources` for tests that require crypto support +* `node_cctest_quic_sources` for tests that require QUIC support +* `node_cctest_inspector_sources` for tests that require the inspector -The only sources that should be included in the cctest target are -actual test or helper source files. There might be a need to include specific -object files that are compiled by the `node` target and this can be done by -adding them to the `libraries` section in the cctest target. +The only sources that should be placed in `test/cctest` are actual test or +helper source files. There might be a need to include specific object files +that are compiled by the `node` target and this can be done by adding them to +the `libraries` section in the cctest target. The test can be executed by running the `cctest` target: From 67af6d1d3917e5c46ee6197d5c621f651cdaf45f Mon Sep 17 00:00:00 2001 From: Tim Perry <1526883+pimterry@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:15:16 +0200 Subject: [PATCH 08/22] quic: fix segfault after fragmented client hello Signed-off-by: Tim Perry PR-URL: https://github.com/nodejs/node/pull/64720 Reviewed-By: Aviv Keller --- src/quic/session.cc | 6 +- .../test-quic-multipacket-clienthello.mjs | 60 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-quic-multipacket-clienthello.mjs diff --git a/src/quic/session.cc b/src/quic/session.cc index 0f5fb0d95258..8466bed35f78 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2813,9 +2813,9 @@ bool Session::ReadPacket(const uint8_t* data, Debug(this, "Session successfully received %zu-byte packet", len); if (!is_destroyed()) [[likely]] { STAT_INCREMENT_N(Stats, bytes_received, len); - // Process deferred operations that couldn't run inside callback - // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). - application().PostReceive(); + // Process deferred application operations after ALPN selection - not + // necessarily resolved yet as ClientHello can span multiple packets. + if (has_application()) application().PostReceive(); // Surface a server session to JS once its ClientHello has been // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). // Held first-flight events - including 0-RTT request streams - replay diff --git a/test/parallel/test-quic-multipacket-clienthello.mjs b/test/parallel/test-quic-multipacket-clienthello.mjs new file mode 100644 index 000000000000..2d701a899fc4 --- /dev/null +++ b/test/parallel/test-quic-multipacket-clienthello.mjs @@ -0,0 +1,60 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// A large post-quantum key share splits the ClientHello across QUIC Initial +// packets. The server must accept the incomplete first packet before ALPN has +// selected its application, then complete the handshake and process streams. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createPrivateKey } = await import('node:crypto'); +const { listen, connect } = await import('node:quic'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const alpn = 'quic-multipacket-clienthello'; +const groups = 'X25519MLKEM768'; +const streamReceived = Promise.withResolvers(); + +const endpoint = await listen(mustCall(async (session) => { + const info = await session.opened; + assert.strictEqual(session.alpnProtocol, alpn); + assert.strictEqual(info.cipherVersion, 'TLSv1.3'); + + session.onstream = mustCall(async (stream) => { + assert.strictEqual( + Buffer.from(await bytes(stream)).toString(), + 'application event survived', + ); + stream.writer.endSync(); + await stream.closed; + streamReceived.resolve(); + }); +}), { + host: '127.0.0.1', + port: 0, + alpn: [alpn], + groups, + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +const session = await connect(endpoint.address, { + alpn, + groups, + servername: 'localhost', + verifyPeer: 'manual', +}); + +await session.opened; +const stream = await session.createBidirectionalStream({ + body: 'application event survived', +}); +await Promise.all([stream.closed, streamReceived.promise]); +await session.close(); +await endpoint.close(); From 756a023fc40b09e506a7168d7ebad84bd17260a6 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Fri, 31 Jul 2026 17:12:21 -0400 Subject: [PATCH 09/22] test: update WPT for url to 4832db4761 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/64829 Reviewed-By: René Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca --- test/fixtures/wpt/url/WEB_FEATURES.yml | 15 ++---- .../wpt/url/resources/setters_tests.json | 5 ++ .../wpt/url/resources/urltestdata.json | 46 +++++++++++++++++++ test/fixtures/wpt/versions.json | 2 +- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/test/fixtures/wpt/url/WEB_FEATURES.yml b/test/fixtures/wpt/url/WEB_FEATURES.yml index b7a784695aa0..a39c0594f47d 100644 --- a/test/fixtures/wpt/url/WEB_FEATURES.yml +++ b/test/fixtures/wpt/url/WEB_FEATURES.yml @@ -1,11 +1,4 @@ -features: -- name: url - files: - - "*" - - "!url-statics-canparse.*" -- name: url-canparse - files: - - url-statics-canparse.* -- name: base - files: - - a-element* +rules: +- url-statics-canparse.*: [url-canparse] +- a-element*: [base, url] +- "*": [url] diff --git a/test/fixtures/wpt/url/resources/setters_tests.json b/test/fixtures/wpt/url/resources/setters_tests.json index 0a151f91862a..221e77a951bb 100644 --- a/test/fixtures/wpt/url/resources/setters_tests.json +++ b/test/fixtures/wpt/url/resources/setters_tests.json @@ -1900,6 +1900,7 @@ "href": "https://domain.com:443", "new_value": "\u00098080", "expected": { + "href": "https://domain.com:8080/", "port": "8080" } }, @@ -1908,6 +1909,7 @@ "href": "wpt++://domain.com:443", "new_value": "\u00098080", "expected": { + "href": "wpt++://domain.com:8080", "port": "8080" } }, @@ -1916,6 +1918,7 @@ "href": "https://www.google.com:4343", "new_value": "4wpt", "expected": { + "href": "https://www.google.com:4/", "port": "4" } }, @@ -1923,6 +1926,7 @@ "href": "https://domain.com:3000", "new_value": "\n\t80\n\t80\n\t", "expected": { + "href": "https://domain.com:8080/", "port": "8080" } }, @@ -1930,6 +1934,7 @@ "href": "https://domain.com:3000", "new_value": "\n\n\t\t", "expected": { + "href": "https://domain.com:3000/", "port": "3000" } } diff --git a/test/fixtures/wpt/url/resources/urltestdata.json b/test/fixtures/wpt/url/resources/urltestdata.json index c8d6ffe22bff..c4807f9e2cfc 100644 --- a/test/fixtures/wpt/url/resources/urltestdata.json +++ b/test/fixtures/wpt/url/resources/urltestdata.json @@ -4373,6 +4373,52 @@ "search": "", "hash": "" }, + "Astral code point followed by a trailing character in the userinfo", + { + "input": "http://😀x@host/", + "base": null, + "href": "http://%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80x", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a:😀x@host/", + "base": null, + "href": "http://a:%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "a", + "password": "%F0%9F%98%80x", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://😀@host/", + "base": null, + "href": "http://%F0%9F%98%80@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, { "input": "https://localhost?q=🔥", "base": null, diff --git a/test/fixtures/wpt/versions.json b/test/fixtures/wpt/versions.json index ef4ac92ba8b3..32b9537ffca9 100644 --- a/test/fixtures/wpt/versions.json +++ b/test/fixtures/wpt/versions.json @@ -72,7 +72,7 @@ "path": "streams" }, "url": { - "commit": "b63305b743ed9ce2725d4ae09c5c8f0c40d8e6e1", + "commit": "4832db47614f5f48cc57374cbf5c1f70937fad48", "path": "url" }, "urlpattern": { From c543cfb72b7d6591b9a469b57b7097b41ef732e0 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Fri, 31 Jul 2026 23:33:39 +0100 Subject: [PATCH 10/22] stream: use the ring buffer for pending BYOB pull-into descriptors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The byte controller's [[pendingPullIntos]] list was still a plain array consumed with ArrayPrototypeShift, while every other per-chunk queue in the WHATWG streams implementation has moved to the Queue ring buffer. BYOB reads push and shift one descriptor per read, and Array.prototype shift has real per-call cost even at length 1. Back the descriptor list with the same lazily materialized Queue used for the request queues, so constructing a byte stream still allocates no descriptor storage. Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/64818 Reviewed-By: Yagiz Nizipli Reviewed-By: Paolo Insogna Reviewed-By: Aviv Keller Reviewed-By: Gürgün Dayıoğlu --- benchmark/webstreams/tee.js | 44 +++++ lib/internal/webstreams/readablestream.js | 199 +++++++++++--------- test/parallel/test-whatwg-readablestream.js | 7 +- 3 files changed, 164 insertions(+), 86 deletions(-) create mode 100644 benchmark/webstreams/tee.js diff --git a/benchmark/webstreams/tee.js b/benchmark/webstreams/tee.js new file mode 100644 index 000000000000..2b1b4802dd92 --- /dev/null +++ b/benchmark/webstreams/tee.js @@ -0,0 +1,44 @@ +'use strict'; +const common = require('../common.js'); +const { ReadableStream } = require('node:stream/web'); + +const bench = common.createBenchmark(main, { + n: [1e5], + type: ['normal', 'bytes'], +}); + +async function main({ n, type }) { + let i = 0; + const source = type === 'bytes' ? + { + type: 'bytes', + pull(controller) { + if (i++ < n) controller.enqueue(new Uint8Array(16)); + else controller.close(); + }, + } : + { + pull(controller) { + if (i++ < n) controller.enqueue('a'); + else controller.close(); + }, + }; + + const rs = new ReadableStream(source); + const [branch1, branch2] = rs.tee(); + const reader1 = branch1.getReader(); + const reader2 = branch2.getReader(); + let reads = 0; + + bench.start(); + for (;;) { + const [result1, result2] = await Promise.all([ + reader1.read(), + reader2.read(), + ]); + if (result1.done || result2.done) break; + reads++; + } + bench.end(reads); + console.assert(reads === n); +} diff --git a/lib/internal/webstreams/readablestream.js b/lib/internal/webstreams/readablestream.js index 9b6dc4b4ad21..b25166957614 100644 --- a/lib/internal/webstreams/readablestream.js +++ b/lib/internal/webstreams/readablestream.js @@ -7,7 +7,6 @@ const { ArrayBufferPrototypeSlice, ArrayBufferPrototypeTransfer, ArrayPrototypePush, - ArrayPrototypeShift, DataView, FunctionPrototypeBind, FunctionPrototypeCall, @@ -1258,7 +1257,7 @@ class ReadableByteStreamController { byteOffset, bytesFilled, byteLength, - } = this[kState].pendingPullIntos[0]; + } = this[kState].pendingPullIntos.peek(); const view = new Uint8Array( buffer, @@ -1341,9 +1340,11 @@ class ReadableByteStreamController { pendingPullIntos, } = this[kState]; if (pendingPullIntos.length > 0) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); firstPendingPullInto.type = 'none'; - this[kState].pendingPullIntos = [firstPendingPullInto]; + const queue = new Queue(); + queue.push(firstPendingPullInto); + this[kState].pendingPullIntos = queue; } } @@ -1804,29 +1805,42 @@ function readableStreamDefaultTee(stream, cloneForBranch2) { let branch2; const cancelPromise = PromiseWithResolvers(); + // At most one read is ever in flight (`reading` guards pullAlgorithm), + // so one read request object and one forwarding microtask function are + // reused for every chunk; the chunk travels through `pendingChunk`. + // The request is materialized lazily on the first pull so that tee() + // itself stays allocation-light. + let pendingChunk; + let readRequest; + function forwardChunk() { + reading = false; + const value1 = pendingChunk; + let value2 = pendingChunk; + pendingChunk = undefined; + if (!canceled2 && cloneForBranch2) { + value2 = structuredClone(value2); + } + if (!canceled1) { + readableStreamDefaultControllerEnqueue( + branch1[kState].controller, + value1); + } + if (!canceled2) { + readableStreamDefaultControllerEnqueue( + branch2[kState].controller, + value2); + } + } + async function pullAlgorithm() { if (reading) return; reading = true; - const readRequest = { + readRequest ??= { [kChunk](value) { - queueMicrotask(() => { - reading = false; - const value1 = value; - let value2 = value; - if (!canceled2 && cloneForBranch2) { - value2 = structuredClone(value2); - } - if (!canceled1) { - readableStreamDefaultControllerEnqueue( - branch1[kState].controller, - value1); - } - if (!canceled2) { - readableStreamDefaultControllerEnqueue( - branch2[kState].controller, - value2); - } - }); + // The microtask is required by the spec (ReadableStreamTee's + // "chunk steps" queue one). + pendingChunk = value; + queueMicrotask(forwardChunk); }, [kClose]() { // The `process.nextTick()` is not part of the spec. @@ -1920,6 +1934,55 @@ function readableByteStreamTee(stream) { ); } + // As in readableStreamDefaultTee, only one read is ever in flight, so + // the default-reader read request and its forwarding microtask are + // shared across all chunks. + let pendingChunk; + function forwardChunk() { + readAgainForBranch1 = false; + readAgainForBranch2 = false; + const chunk1 = pendingChunk; + let chunk2 = pendingChunk; + pendingChunk = undefined; + + if (!canceled1 && !canceled2) { + try { + chunk2 = cloneAsUint8Array(chunk1); + } catch (error) { + readableByteStreamControllerError( + branch1[kState].controller, + error, + ); + readableByteStreamControllerError( + branch2[kState].controller, + error, + ); + cancelDeferred.resolve(readableStreamCancel(stream, error)); + return; + } + } + if (!canceled1) { + readableByteStreamControllerEnqueue( + branch1[kState].controller, + chunk1, + ); + } + if (!canceled2) { + readableByteStreamControllerEnqueue( + branch2[kState].controller, + chunk2, + ); + } + reading = false; + + if (readAgainForBranch1) { + pull1Algorithm(); + } else if (readAgainForBranch2) { + pull2Algorithm(); + } + } + + let defaultReadRequest; function pullWithDefaultReader() { if (isReadableStreamBYOBReader(reader)) { readableStreamBYOBReaderRelease(reader); @@ -1927,50 +1990,10 @@ function readableByteStreamTee(stream) { forwardReaderError(reader); } - const readRequest = { + defaultReadRequest ??= { [kChunk](chunk) { - queueMicrotask(() => { - readAgainForBranch1 = false; - readAgainForBranch2 = false; - const chunk1 = chunk; - let chunk2 = chunk; - - if (!canceled1 && !canceled2) { - try { - chunk2 = cloneAsUint8Array(chunk); - } catch (error) { - readableByteStreamControllerError( - branch1[kState].controller, - error, - ); - readableByteStreamControllerError( - branch2[kState].controller, - error, - ); - cancelDeferred.resolve(readableStreamCancel(stream, error)); - return; - } - } - if (!canceled1) { - readableByteStreamControllerEnqueue( - branch1[kState].controller, - chunk1, - ); - } - if (!canceled2) { - readableByteStreamControllerEnqueue( - branch2[kState].controller, - chunk2, - ); - } - reading = false; - - if (readAgainForBranch1) { - pull1Algorithm(); - } else if (readAgainForBranch2) { - pull2Algorithm(); - } - }); + pendingChunk = chunk; + queueMicrotask(forwardChunk); }, [kClose]() { reading = false; @@ -1995,8 +2018,7 @@ function readableByteStreamTee(stream) { reading = false; }, }; - - readableStreamDefaultReaderRead(reader, readRequest); + readableStreamDefaultReaderRead(reader, defaultReadRequest); } function pullWithBYOBReader(view, forBranch2) { @@ -2820,7 +2842,7 @@ function readableByteStreamControllerClose(controller) { } if (pendingPullIntos.length) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); if (firstPendingPullInto.bytesFilled % firstPendingPullInto.elementSize !== 0) { const error = new ERR_INVALID_STATE.TypeError('Partial read'); readableByteStreamControllerError(controller, error); @@ -2877,7 +2899,7 @@ function readableByteStreamControllerClearAlgorithms(controller) { function readableByteStreamControllerClearPendingPullIntos(controller) { readableByteStreamControllerInvalidateBYOBRequest(controller); - controller[kState].pendingPullIntos = []; + controller[kState].pendingPullIntos = kEmptyQueue; } function readableByteStreamControllerGetDesiredSize(controller) { @@ -2979,7 +3001,7 @@ function readableByteStreamControllerPullInto( type: 'byob', }; if (pendingPullIntos.length) { - ArrayPrototypePush(pendingPullIntos, desc); + pendingPullIntos.push(desc); readableStreamAddReadIntoRequest(stream, readIntoRequest); return; } @@ -3005,17 +3027,28 @@ function readableByteStreamControllerPullInto( return; } } - ArrayPrototypePush(pendingPullIntos, desc); + materializePendingPullIntos(controller[kState]).push(desc); readableStreamAddReadIntoRequest(stream, readIntoRequest); readableByteStreamControllerCallPullIfNeeded(controller); } +// Pending pull-into descriptor queues start out as (and are reset to) +// the shared immutable empty queue so that constructing a byte stream +// never allocates descriptor storage; the two push sites that can +// observe an empty queue materialize a real Queue on first use. +function materializePendingPullIntos(state) { + const pendingPullIntos = state.pendingPullIntos; + if (pendingPullIntos === kEmptyQueue) + return state.pendingPullIntos = new Queue(); + return pendingPullIntos; +} + function readableByteStreamControllerRespondInternal(controller, bytesWritten) { const { stream, pendingPullIntos, } = controller[kState]; - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); readableByteStreamControllerInvalidateBYOBRequest(controller); if (stream[kState].state === 'closed') { if (bytesWritten) @@ -3040,7 +3073,7 @@ function readableByteStreamControllerRespond(controller, bytesWritten) { stream, } = controller[kState]; assert(pendingPullIntos.length); - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); if (stream[kState].state === 'closed') { if (bytesWritten !== 0) @@ -3085,7 +3118,7 @@ function readableByteStreamControllerFillHeadPullIntoDescriptor( pendingPullIntos, byobRequest, } = controller[kState]; - assert(!pendingPullIntos.length || pendingPullIntos[0] === desc); + assert(!pendingPullIntos.length || pendingPullIntos.peek() === desc); assert(byobRequest === null); desc.bytesFilled += size; } @@ -3108,7 +3141,7 @@ function readableByteStreamControllerEnqueue(controller, chunk) { const transferredBuffer = ArrayBufferPrototypeTransfer(buffer); if (pendingPullIntos.length) { - const firstPendingPullInto = pendingPullIntos[0]; + const firstPendingPullInto = pendingPullIntos.peek(); if (ArrayBufferPrototypeGetDetached(firstPendingPullInto.buffer)) { throw new ERR_INVALID_STATE.TypeError( @@ -3155,7 +3188,7 @@ function readableByteStreamControllerEnqueue(controller, chunk) { } else { assert(!queue.length); if (pendingPullIntos.length) { - assert(pendingPullIntos[0].type === 'default'); + assert(pendingPullIntos.peek().type === 'default'); readableByteStreamControllerShiftPendingPullInto(controller); } const transferredView = @@ -3324,7 +3357,7 @@ function readableByteStreamControllerProcessPullIntoDescriptorsUsingQueue( while (pendingPullIntos.length) { if (!controller[kState].queueTotalSize) break; - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); if (readableByteStreamControllerFillPullIntoDescriptorFromQueue( controller, desc)) { @@ -3404,7 +3437,7 @@ function readableByteStreamControllerRespondWithNewView(controller, view) { } = controller[kState]; assert(pendingPullIntos.length); - const desc = pendingPullIntos[0]; + const desc = pendingPullIntos.peek(); assert(stream[kState].state !== 'errored'); const viewByteLength = ArrayBufferViewGetByteLength(view); @@ -3444,7 +3477,7 @@ function readableByteStreamControllerRespondWithNewView(controller, view) { function readableByteStreamControllerShiftPendingPullInto(controller) { assert(controller[kState].byobRequest === null); - return ArrayPrototypeShift(controller[kState].pendingPullIntos); + return controller[kState].pendingPullIntos.shift(); } function readableByteStreamControllerCallPullIfNeeded(controller) { @@ -3540,7 +3573,6 @@ function readableByteStreamControllerProcessReadRequestsUsingQueue(controller) { function readableByteStreamControllerPullSteps(controller, readRequest) { const { - pendingPullIntos, queueTotalSize, stream, } = controller[kState]; @@ -3559,8 +3591,7 @@ function readableByteStreamControllerPullSteps(controller, readRequest) { if (autoAllocateChunkSize !== undefined) { try { const buffer = new ArrayBuffer(autoAllocateChunkSize); - ArrayPrototypePush( - pendingPullIntos, + materializePendingPullIntos(controller[kState]).push( { buffer, bufferByteLength: autoAllocateChunkSize, @@ -3610,7 +3641,7 @@ function setupReadableByteStreamController( pullAlgorithm, cancelAlgorithm, autoAllocateChunkSize, - pendingPullIntos: [], + pendingPullIntos: kEmptyQueue, }; stream[kState].controller = controller; diff --git a/test/parallel/test-whatwg-readablestream.js b/test/parallel/test-whatwg-readablestream.js index d1001d51b4e8..0105d4bf723b 100644 --- a/test/parallel/test-whatwg-readablestream.js +++ b/test/parallel/test-whatwg-readablestream.js @@ -37,7 +37,8 @@ const { } = require('internal/webstreams/readablestream'); const { - kState + kState, + Queue, } = require('internal/webstreams/util'); const { @@ -1581,7 +1582,9 @@ class Source { start(c) { controller = c; } }); - controller[kState].pendingPullIntos = [{}]; + const pendingPullIntos = new Queue(); + pendingPullIntos.push({}); + controller[kState].pendingPullIntos = pendingPullIntos; assert.throws(() => readableByteStreamControllerRespond(controller, 0), { code: 'ERR_INVALID_ARG_VALUE', }); From b7d29fee74fd380e9cfae3fd7a186f5c22439451 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Wed, 29 Jul 2026 14:17:04 -0700 Subject: [PATCH 11/22] test_runner: add support for --test-coverage-include-all Signed-off-by: avivkeller PR-URL: https://github.com/nodejs/node/pull/64830 Fixes: https://github.com/nodejs/node/issues/58887 Reviewed-By: Moshe Atlow Reviewed-By: Chemi Atlow --- doc/api/cli.md | 16 +++++ doc/api/test.md | 7 ++ doc/node.1 | 9 +++ lib/internal/test_runner/coverage.js | 50 ++++++++++++- lib/internal/test_runner/runner.js | 3 + lib/internal/test_runner/utils.js | 2 + src/node_options.cc | 7 ++ src/node_options.h | 1 + .../coverage-include-all/covered.js | 5 ++ .../coverage-include-all/data.json | 3 + .../coverage-include-all/index.test.js | 9 +++ .../coverage-include-all/nested/deep.js | 5 ++ .../node_modules/pkg/index.js | 5 ++ .../coverage-include-all/untested.js | 5 ++ test/parallel/test-runner-coverage.js | 70 +++++++++++++++++++ 15 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/test-runner/coverage-include-all/covered.js create mode 100644 test/fixtures/test-runner/coverage-include-all/data.json create mode 100644 test/fixtures/test-runner/coverage-include-all/index.test.js create mode 100644 test/fixtures/test-runner/coverage-include-all/nested/deep.js create mode 100644 test/fixtures/test-runner/coverage-include-all/node_modules/pkg/index.js create mode 100644 test/fixtures/test-runner/coverage-include-all/untested.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 76f2aecf75b6..9434e19d3b73 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -2877,6 +2877,21 @@ This option may be specified multiple times to include multiple glob patterns. If both `--test-coverage-exclude` and `--test-coverage-include` are provided, files must meet **both** criteria to be included in the coverage report. +### `--test-coverage-include-all` + + + +> Stability: 1 - Experimental + +Includes source files that were never loaded by the test run in the coverage +report, where they are reported as having zero coverage. + +Candidate files are searched for in the current working directory, and are +subject to the same `--test-coverage-include` and `--test-coverage-exclude` +filtering as the rest of the report. + ### `--test-coverage-lines=threshold` + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +An in-memory, **zero-copy** view over the entries of a ZIP archive already +held in a `Buffer`, `TypedArray`, `DataView`, or `ArrayBuffer`. Its set of +entries can be edited - entries added or removed - but, unlike [`ZipFile`][], +those edits are **not** written into the source buffer: a newly added entry is +held as a separate in-memory [`ZipEntry`][] (the passed buffer is a fixed-size +view with no room to append to), and removal just drops the entry from +`ZipBuffer`'s index. The original bytes are never modified. +[`zipBuffer.toBuffer()`][] serializes the current set of entries into a fresh +archive. + +`ZipBuffer` does not copy the archive you hand it. It keeps a view onto that +memory and reads each entry's content lazily and directly from it, which is +what makes construction cheap regardless of archive size. The trade-off is +that you **must not modify or reuse** that memory - including the +`ArrayBuffer` backing a `TypedArray`/`DataView` - while the `ZipBuffer`, or +any [`ZipEntry`][] obtained from it, is still in use: a later read would +observe the change and may fail or return corrupt data. Pass a copy (for +example `Buffer.from(source)`) if the source might be mutated or reused. + +`add()` and `toBuffer()` each have a `*Sync` counterpart +([`addSync()`][`zipBuffer.addSync()`], [`toBufferSync()`][`zipBuffer.toBufferSync()`]) +that performs the same compression work synchronously. As with the +synchronous `node:fs` APIs, these block the Node.js event loop and further +JavaScript execution until the operation completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +```mjs +import { ZipBuffer } from 'node:zlib'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { Buffer } from 'node:buffer'; + +const zip = new ZipBuffer(readFileSync('archive.zip')); +for (const [name, entry] of zip) { + console.log(name, entry.size); +} +await zip.add('hello.txt', Buffer.from('Hello, world!')); +zip.delete('unwanted.txt'); +writeFileSync('archive.zip', await zip.toBuffer()); +``` + +```cjs +const { ZipBuffer } = require('node:zlib'); +const { readFileSync, writeFileSync } = require('node:fs'); + +async function main() { + const zip = new ZipBuffer(readFileSync('archive.zip')); + for (const [name, entry] of zip) { + console.log(name, entry.size); + } + await zip.add('hello.txt', Buffer.from('Hello, world!')); + zip.delete('unwanted.txt'); + writeFileSync('archive.zip', await zip.toBuffer()); +} +main(); +``` + +### `new zlib.ZipBuffer(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. + +Parses the archive's central directory. Throws an [`ERR_ZIP_INVALID_ARCHIVE`][] +or [`ERR_ZIP_UNSUPPORTED_FEATURE`][] error if `buffer` is not a well-formed, +supported archive. + +`buffer` is **not copied**: the `ZipBuffer` retains a zero-copy view of it (for +a `TypedArray`, `DataView`, or `ArrayBuffer`, of the underlying `ArrayBuffer`) +and reads entry content directly from it on demand. Do not mutate or reuse that +memory while the `ZipBuffer` or any entry read from it is still live; pass a +copy if it might change. + +### `zipBuffer.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipBuffer.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipBuffer.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipBuffer.add()`][]. Equivalent to +`zipBuffer.addEntry(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipBuffer.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +Adds an already-built entry, keyed by its own [`zipEntry.name`][]. Replaces +any existing entry of that name. + +### `zipBuffer.clear()` + + + +Removes every entry. + +### `zipBuffer.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipBuffer.toBuffer()`][] calls unless overridden. The bytes are decoded as +UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries no +encoding flag of its own). + +### `zipBuffer.delete(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was removed. + +### `zipBuffer.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + [`ZipEntry`][]. + +### `zipBuffer.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +Calls `callback` once for each entry, in the order the archive lists them. + +### `zipBuffer.get(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry named `name`. + +### `zipBuffer.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipBuffer.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipBuffer.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipBuffer.toBuffer([options])` + + + +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. **Default:** [`zipBuffer.comment`][]. + * `baseOffset` {number} Shifts every offset the archive records by this + many bytes, so the serialized archive is self-describing even when it is + written somewhere other than the start of its eventual file - for example, + after `baseOffset` bytes of other content already written to the same + output. **Default:** `0`. +* Returns: {Promise} Fulfilled with a {Buffer} containing the serialized + archive. + +Serializes the current set of entries - in the order they were added or +read - into a fresh archive, switching to Zip64 structures automatically as +needed (see [`zlib.createZipArchive()`][]). + +### `zipBuffer.toBufferSync([options])` + + + +* `options` {string|Object} See [`zipBuffer.toBuffer()`][]. +* Returns: {Buffer} The serialized archive. + +The synchronous version of [`zipBuffer.toBuffer()`][] (see +[`zlib.createZipArchiveSync()`][]). + +### `zipBuffer.values()` + + + +* Returns: {Iterator} of [`ZipEntry`][]. + +### `zipBuffer.writable` + + + +* Type: {boolean} + +Always `true`. + +## Class: `zlib.ZipEntry` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +A single file or directory inside a ZIP archive. Instances are produced by +[`ZipBuffer`][] and [`ZipFile`][], or created directly for writing with +`ZipEntry.create()`/`ZipEntry.createStream()`. + +`create()` and `content()` each have a `*Sync` counterpart (the streaming +`contentIterator()` does not). As with the synchronous `node:fs` APIs, these +block the +Node.js event loop and further JavaScript execution until the operation +(including any deflate/inflate pass) completes; use them only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. + +### Static method: `zlib.ZipEntry.create(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644` (`0o755` for + directories). + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`, except for directories and empty content, which are always + stored. +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Compresses `data` (unless `method` is `'store'`, or compression would not +reduce its size) and computes its CRC-32. + +When the entry ends up stored uncompressed (because `method` is `'store'`, +or because compression would not reduce the size), the entry retains a +zero-copy view of `data` rather than a copy, and its CRC-32 has already been +recorded. Do not mutate `data` after creating the entry; pass a copy if it +might change. + +The MS-DOS date/time fields ZIP uses for `modified` have 2-second resolution +and no time zone. When `modified` does not fall on a whole 2-second +boundary, an Info-ZIP extended-timestamp extra field is written as well, +recording the whole (UTC) second so the time round-trips more precisely (see +[`zipEntry.modified`][]). This applies to every entry-creation path. + +### Static method: `zlib.ZipEntry.createStream(filename, source[, options])` + + + +* `filename` {string} The entry's name within the archive. Must not end + in `/`. +* `source` {AsyncIterable} Yields the entry's uncompressed content as + `Uint8Array` chunks. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o644`. + * `modified` {Date} The entry's modification time. **Default:** the + current time. + * `method` {string} One of `'deflate'`, `'store'`, or `'zstd'`. **Default:** + `'deflate'`. +* Returns: {ZipEntry} + +Creates an entry whose content is compressed on the fly as it is serialized +by [`zlib.createZipArchive()`][], without buffering `source` in memory. Its +`size`, `compressedSize`, and `crc32` only become available once +serialization has finished. There is no synchronous counterpart: streaming +entries only make sense with an asynchronous, incrementally-produced +`source`. + +`source` is drained exactly once, during serialization. Until that happens +the entry has no readable content, so [`zipEntry.content()`][], +[`zipEntry.contentSync()`][], and [`zipEntry.contentIterator()`][] throw +[`ERR_INVALID_STATE`][]. If the entry is serialized by adding it to a writable +[`ZipFile`][] with [`zipFile.addEntry()`][] (or `addEntrySync()`), it is then +**promoted in place** to a file-backed entry pointing at the copy just written, +so it becomes readable (and can be serialized again) for as long as that +`ZipFile` stays open. Serializing it any other way (for example directly +through [`zlib.createZipArchive()`][]) leaves it spent and unreadable. + +Because `source` may hold an operating-system resource (a file read stream, +say), a streaming entry is disposable: its `Symbol.dispose` and +`Symbol.asyncDispose` methods destroy `source` if it has not been consumed. +An entry passed to an archive is disposed by that archive (see +[`zlib.createZipArchive()`][]); dispose an entry directly only when it was +built but never handed to one. Disposal is a no-op for non-streaming entries - +in particular a file-backed entry never closes the [`ZipFile`][] descriptor it +borrows. + +### Static method: `zlib.ZipEntry.createSymlink(filename, target[, options])` + + + +* `filename` {string} The entry's name within the archive. +* `target` {string} The symbolic link's target path. +* `options` {Object} + * `comment` {string} An entry comment. + * `mode` {integer} Unix permission bits. **Default:** `0o777`. + * `modified` {Date} The entry's modification time. **Default:** the current + time. +* Returns: {ZipEntry} + +Creates a symbolic-link entry: a stored entry whose content is `target` and +whose Unix mode type bits mark it as a symlink, so [`zipEntry.isSymlink`][] is +`true` when it is read back. Extraction tools that honor symlink entries +recreate the link; treat `target` as untrusted (see [`zipEntry.name`][] on +path safety). + +### Static method: `zlib.ZipEntry.createSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. Must be empty when `filename` names a directory. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {ZipEntry} + +The synchronous version of [`zlib.ZipEntry.create()`][]. + +### Static method: `zlib.ZipEntry.read(buffer)` + + + +* `buffer` {Buffer|TypedArray|DataView|ArrayBuffer} A complete ZIP archive. +* Returns: {Iterator} of {ZipEntry}. + +Parses every entry out of `buffer` directly, without indexing it into a +[`ZipBuffer`][]. Like [`ZipBuffer`][], the yielded entries hold zero-copy views +of `buffer` rather than copies of their content, so the same rule applies: do +not mutate or reuse `buffer` while any of them is still in use. + +### `zipEntry.comment` + + + +* Type: {string} + +### `zipEntry.compressed` + + + +* Type: {boolean} + +`true` if the entry's content is stored in compressed form (any compression +method, currently deflate or Zstandard); `false` if it is stored +uncompressed. + +### `zipEntry.compressedSize` + + + +* Type: {number} + +### `zipEntry.content([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before allocating anything. **Default:** + [`zlib.getMaxZipContentSize()`][]. +* Returns: {Promise} Fulfilled with a {Buffer} containing the entry's + decompressed content. The buffer is a fresh copy that shares no memory + with the archive or with data the entry was created from. + +Throws an [`ERR_ZIP_ENTRY_TOO_LARGE`][] error if the entry's declared size +exceeds `maxSize`, an [`ERR_ZIP_ENTRY_CORRUPT`][] error if the content fails +CRC-32 verification or does not match its declared size, and an +[`ERR_INVALID_STATE`][] error for a streaming entry +([`zlib.ZipEntry.createStream()`][]) whose content is not yet available (see +that method for when a streaming entry becomes readable). + +### `zipEntry.contentSync([options])` + + + +* `options` {Object} See [`zipEntry.content()`][]. +* Returns: {Buffer} The entry's decompressed content. + +The synchronous version of [`zipEntry.content()`][]. + +### `zipEntry.contentIterator([options])` + + + +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes, before decompressing anything. **Default:** no limit. +* Returns: {AsyncIterator} of {Buffer} chunks of the entry's decompressed + content. + +Unlike [`zipEntry.content()`][], this does not buffer the whole member in +memory. For a file-backed entry (one returned by [`zipFile.get()`][]) the +compressed bytes are read from disk as the iterator is consumed and nothing is +retained; the entry is valid only while its `ZipFile` is open. + +Because streaming is the bounded-memory path for arbitrarily large members, it +is **not** capped by [`zlib.getMaxZipContentSize()`][] the way +[`zipEntry.content()`][] is - that default guards a single large allocation, +which streaming never makes. Output is still bounded per chunk to the declared +uncompressed size; pass `maxSize` to impose an explicit ceiling. + +For an in-memory entry stored without compression, the yielded chunks are +zero-copy views of the entry's retained content (see +[`zipEntry.rawContent`][]); do not mutate them. + +The yielded chunks are **provisional until the iterator completes**. CRC-32 +verification (and the final declared-size check) can only run once every byte +has been read, so a corrupt or truncated entry is reported by the iterator +throwing _after_ the last chunk, not before the first. Each chunk is still +bounded so the total never exceeds the declared size or `maxSize`, but a +consumer that must not act on unverified bytes should buffer them (or use +[`zipEntry.content()`][], which verifies before returning anything) rather than +processing chunks as they arrive. + +### `zipEntry.crc32` + + + +* Type: {number} + +### `zipEntry.flags` + + + +* Type: {number} + +The entry's raw general-purpose bit flag. + +### `zipEntry.isDirectory` + + + +* Type: {boolean} + +`true` if the entry is a directory (its name ends with `/`). + +### `zipEntry.isFile` + + + +* Type: {boolean} + +`true` if the entry is a regular file — that is, neither a directory nor a +symbolic link. + +### `zipEntry.isSymlink` + + + +* Type: {boolean} + +`true` if the entry is a symbolic link (its Unix mode type bits are +`S_IFLNK`); its content is the link target. Always `false` for archives not +written on a Unix-like system. When extracting, treat a symlink's target as +untrusted — see [`zipEntry.name`][] on path safety. + +### `zipEntry.mode` + + + +* Type: {number} + +The entry's Unix mode permission bits, including the setuid, setgid, and +sticky bits (the low 12 bits, `0o7777`), or `0` if the archive was not written +on a Unix-like system. The file-type bits are not included here; use +[`zipEntry.isDirectory`][] / [`zipEntry.isSymlink`][] for the type. + +### `zipEntry.modified` + + + +* Type: {Date} + +The entry's last-modification time. When the archive carries a higher-fidelity +timestamp in an extra field — an NTFS (`0x000a`), Info-ZIP extended (`0x5455`), +or Info-ZIP Unix (`0x5855`) field, as most modern tools write — that absolute +(UTC) time is used; otherwise the coarse, local-time MS-DOS date/time field +(2-second resolution) is used. + +Some tools store their high-fidelity timestamp only in the local file header, +so on a file-backed entry (one returned by [`zipFile.get()`][]) the first read +of this property may perform a small synchronous positioned disk read to +resolve that header. If that read fails, the value silently falls back to the +central-directory data. + +### `zipEntry.method` + + + +* Type: {number} + +The entry's raw compression method: `0` for stored, `8` for deflate, `93` +for Zstandard. + +### `zipEntry.name` + + + +* Type: {string} + +The entry's name, decoded from the central directory, which is treated as +authoritative — a local file header that disagrees is ignored, so a +mismatched-header ("ZIP-confusion") archive cannot make `name` disagree with +what is read. The bytes are decoded from a valid Info-ZIP Unicode Path extra +field (`0x7075`) when one is present; otherwise as UTF-8 when the +language-encoding flag (general-purpose bit 11) is set **or the bytes are +valid UTF-8** (plenty of tools wrote UTF-8 names without ever setting the +flag); and as CP437 — the historical default — only when they are not. +See [`zipEntry.nameBuffer`][] for the raw bytes. + +The name is returned **verbatim**: it is never normalized, and a name +containing `..`, a leading `/`, a drive letter, or backslashes is neither +rewritten nor rejected. A `ZipFile`/`ZipBuffer` never writes to disk, so +guarding against path traversal ("Zip Slip") when extracting is the caller's +responsibility. + +### `zipEntry.nameBuffer` + + + +* Type: {Buffer} + +The entry's raw name bytes, before any character decoding. Useful when the +archive's names are in an encoding other than UTF-8 or CP437 and the caller +wants to decode them itself. + +### `zipEntry.rawContent` + + + +* Type: {Buffer|null} + +The entry's raw (still compressed, if applicable) content when it is held in +memory, or `null` when there is no in-memory buffer to expose - for an entry +created with [`zlib.ZipEntry.createStream()`][], or a file-backed entry +returned by [`zipFile.get()`][], whose bytes are read from disk on demand +rather than retained. Use [`zipEntry.content()`][] or +[`zipEntry.contentIterator()`][] to read a file-backed entry. + +### `zipEntry.size` + + + +* Type: {number} + +The entry's uncompressed size, in bytes. + +## Class: `zlib.ZipFile` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this class among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +A random-access view over the entries of a ZIP archive on disk. Only the +archive's tail and central directory are read up front; member content is +read from disk lazily, on demand. Writable when opened with +`{ writable: true }`: [`zipFile.addEntry()`][]/[`zipFile.add()`][] append the +new member's data where the central directory used to be, then rewrite the +central directory immediately after it; [`zipFile.delete()`][] just rewrites +the central directory. Both mean the file is altered as soon as the method's +returned `Promise` fulfills. Deleted or replaced members are left behind as +dead space; [`zipFile.compact()`][] produces a stream with none. + +These in-place edits are **not crash-atomic**. Rewriting the central directory +happens in place, so a write that fails partway - the disk fills, the device +disconnects, the process is killed - can leave the archive on disk with a +partial or missing central directory, i.e. unreadable, even though the member +data before it is intact. The rejected call surfaces the underlying error and +the `ZipFile` object is left usable (its in-memory view is not discarded, so a +caller can attempt recovery - for example re-writing the entries elsewhere with +[`zipFile.compact()`][]), but that in-memory view may no longer match the bytes +on disk. Write to a copy, or `compact()` into a fresh file, when durability +across a failure matters. + +Every method has a `*Sync` counterpart. As with the synchronous `node:fs` +APIs, these block the Node.js event loop and further JavaScript execution +until the operation completes; use them only where synchronous execution is +appropriate (for example, short-lived scripts or startup code), not in code +that must stay responsive. A synchronous method throws `ERR_INVALID_STATE` +if called while an asynchronous `add()`, `addEntry()`, `delete()`, or +`close()` on the same `ZipFile` has not settled yet, since letting the two +interleave could corrupt the archive. + +```mjs +import { ZipFile } from 'node:zlib'; +import { Buffer } from 'node:buffer'; + +const zip = await ZipFile.open('archive.zip', { writable: true }); +try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); +} finally { + await zip.close(); +} +``` + +```cjs +const { ZipFile } = require('node:zlib'); + +async function main() { + const zip = await ZipFile.open('archive.zip', { writable: true }); + try { + const entry = await zip.get('member.txt'); + console.log((await entry.content()).toString()); + for await (const chunk of await zip.stream('huge.bin')) { + // Process each chunk without buffering the whole member. + } + await zip.add('new.txt', Buffer.from('hello')); + await zip.delete('unwanted.txt'); + } finally { + await zip.close(); + } +} +main(); +``` + +### Static method: `zlib.ZipFile.open(filename[, options])` + + + +* `filename` {string} +* `options` {Object} + * `writable` {boolean} Open the underlying file for both reading and + writing (`'r+'`), enabling [`zipFile.addEntry()`][]/[`zipFile.add()`][]/ + [`zipFile.delete()`][]. **Default:** `false`. +* Returns: {Promise} Fulfilled with a {ZipFile}. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive's central +directory is too large to buffer in memory. + +### Static method: `zlib.ZipFile.openSync(filename[, options])` + + + +* `filename` {string} +* `options` {Object} See [`zlib.ZipFile.open()`][]. +* Returns: {ZipFile} + +The synchronous version of [`zlib.ZipFile.open()`][]. + +### `zipFile.add(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.create()`][]. +* Returns: {Promise} Fulfilled with the created {ZipEntry}. + +Equivalent to `zipFile.addEntry(await zlib.ZipEntry.create(filename, data, +options))`. + +### `zipFile.addEntry(entry)` + + + +* `entry` {ZipEntry} +* Returns: {Promise} Fulfilled with `entry`. + +Writes `entry` where the central directory currently starts, then rewrites +the central directory to include it, replacing any existing entry of the +same name. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was not opened +with `{ writable: true }`. + +The returned (same) `entry` is left readable: a streaming entry created with +[`zlib.ZipEntry.createStream()`][], which would otherwise be spent once +serialized, is promoted in place to a file-backed entry pointing at the copy +just written (valid while this `ZipFile` is open). In-memory entries keep their +own buffer unchanged. + +### `zipFile.addEntrySync(entry)` + + + +* `entry` {ZipEntry} +* Returns: {ZipEntry} `entry`. + +The synchronous version of [`zipFile.addEntry()`][]. `entry` must not be a +pending streaming entry (one created with +[`zlib.ZipEntry.createStream()`][]) - there is no synchronous way to drain +its asynchronous source. + +### `zipFile.addSync(filename, data[, options])` + + + +* `filename` {string} The entry's name within the archive. A trailing `/` + marks a directory entry. +* `data` {Buffer|TypedArray|DataView|ArrayBuffer} The entry's complete, + uncompressed content. +* `options` {Object} See [`zlib.ZipEntry.createSync()`][]. +* Returns: {ZipEntry} The created entry. + +The synchronous version of [`zipFile.add()`][]. Equivalent to +`zipFile.addEntrySync(zlib.ZipEntry.createSync(filename, data, options))`. + +### `zipFile.close()` + + + +* Returns: {Promise} + +Closes the underlying file handle. + +Closing does not invalidate outstanding objects: `ZipEntry` objects previously +returned by [`zipFile.get()`][] and the `ZipFile`'s own methods will fail with +system-level errors (for example `EBADF`) if used after close, rather than a +dedicated Node.js error code. The same applies to [`zipFile.closeSync()`][]. + +### `zipFile.closeSync()` + + + +The synchronous version of [`zipFile.close()`][]. + +### `zipFile.comment` + + + +* Type: {string} + +The archive-level comment, preserved byte-for-byte across +[`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. The bytes are decoded +as UTF-8 when they are valid UTF-8 and as CP437 otherwise (the field carries +no encoding flag of its own). + +### `zipFile.compact([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {stream.Readable} A stream of the currently live entries, + serialized as a fresh archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +Does not modify the open file; pipe the result into a new one: + +```mjs +import { createWriteStream } from 'node:fs'; +zip.compact().pipe(createWriteStream('compacted.zip')); +``` + +### `zipFile.compactSync([comment])` + + + +* `comment` {string} An archive comment. **Default:** [`zipFile.comment`][]. +* Returns: {Buffer} The currently live entries, serialized as a fresh + archive with no dead space left by prior + [`zipFile.addEntry()`][]/[`zipFile.delete()`][] calls. + +The synchronous version of [`zipFile.compact()`][]. Does not modify the +open file. + +### `zipFile.delete(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with `true` if an entry named `name` existed + and was removed, `false` otherwise. + +Rewrites the central directory without writing any new content - the +archive does not grow. Throws [`ERR_ZIP_NOT_WRITABLE`][] if the `ZipFile` was +not opened with `{ writable: true }`. + +### `zipFile.deleteSync(name)` + + + +* `name` {string} +* Returns: {boolean} `true` if an entry named `name` existed and was + removed, `false` otherwise. + +The synchronous version of [`zipFile.delete()`][]. + +### `zipFile.entries()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a + {Promise} fulfilled with a [`ZipEntry`][]. + +### `zipFile.entriesSync()` + + + +* Returns: {Iterator} of `[name, entry]` pairs, where `entry` is a resolved + [`ZipEntry`][] (not a `Promise`). + +The synchronous version of [`zipFile.entries()`][]. + +### `zipFile.forEach(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +### `zipFile.forEachSync(callback[, thisArg])` + + + +* `callback` {Function} +* `thisArg` {any} + +The synchronous version of [`zipFile.forEach()`][]: `callback` is invoked +with a resolved [`ZipEntry`][] instead of a `Promise`. + +### `zipFile.get(name)` + + + +* `name` {string} +* Returns: {Promise} Fulfilled with a {ZipEntry}. + +Returns a lazy, file-backed [`ZipEntry`][] for `name`. Nothing is read from +disk here and no content is buffered: the returned entry reads (and, for +[`zipEntry.content()`][], decompresses) its member straight from the file on +each access, and the `ZipFile` retains no member content. The entry is valid +only while this `ZipFile` is open. Reading its content later may throw +[`ERR_ZIP_ENTRY_TOO_LARGE`][] if the member is too large to hold in a single +buffer; use [`zipEntry.contentIterator()`][] (or [`zipFile.stream()`][]) +instead. Throws [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.getSync(name)` + + + +* `name` {string} +* Returns: {ZipEntry} + +The synchronous version of [`zipFile.get()`][]. Like `get()`, it reads +nothing up front and only builds the lazy handle, so it does not itself block +on I/O - but reads performed later through the returned entry (such as +[`zipEntry.contentSync()`][]) do; see the note above on synchronous methods. + +### `zipFile.has(name)` + + + +* `name` {string} +* Returns: {boolean} + +### `zipFile.keys()` + + + +* Returns: {Iterator} of entry names. + +### `zipFile.size` + + + +* Type: {number} + +The number of entries in the archive. + +### `zipFile.stream(name[, options])` + + + +* `name` {string} +* `options` {Object} + * `verify` {boolean} Verify the entry's CRC-32 checksum. **Default:** `true`. + * `maxSize` {number} Reject content declaring more than this many + uncompressed bytes. **Default:** no limit. +* Returns: {Promise} Fulfilled with a {stream.Readable} of the member's + decompressed content, without buffering the whole member in memory. + +Convenience wrapper that resolves to a `Readable` over +[`zipEntry.contentIterator()`][] of [`zipFile.get()`][]`(name)`; the +compressed bytes are read from disk as the stream is consumed. The returned +promise rejects with [`ERR_ZIP_ENTRY_NOT_FOUND`][] if the archive has no entry +named `name`. + +### `zipFile.values()` + + + +* Returns: {Iterator} of {Promise} objects, each fulfilled with a + [`ZipEntry`][]. + +### `zipFile.valuesSync()` + + + +* Returns: {Iterator} of resolved [`ZipEntry`][] values (not `Promise`s). + +The synchronous version of [`zipFile.values()`][]. + +### `zipFile.writable` + + + +* Type: {boolean} + +Whether this `ZipFile` was opened with `{ writable: true }`. + ## Class: `zlib.ZlibBase` + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable|AsyncIterable} of [`ZipEntry`][]. +* `options` {string|Object} An archive comment, as a shorthand for + `{ comment: options }`. + * `comment` {string} An archive comment. + * `baseOffset` {number} Shifts every local/central header offset the + archive records by this many bytes, so the emitted stream is + self-describing even when something else is written before it - for + example, appending the archive after `baseOffset` bytes already written to + the same file, rather than at its start. **Default:** `0`. +* Returns: {stream.Readable} A byte stream of the serialized archive. + +Serializes `entries` into a ZIP archive, switching to Zip64 structures +automatically once the entry count, or any offset or size, exceeds what the +classic 32-/16-bit ZIP fields can hold. The returned `Readable` is also an +`AsyncIterable` of the same {Buffer} chunks it streams. + +Entries are written in iteration order and nothing deduplicates names: an +iterable that yields two entries with the same name produces an archive +containing both, and most extraction tools keep the one that appears later. +[`ZipBuffer`][] and [`ZipFile`][] `add()` methods replace entries by name +instead. + +The entries are owned by the returned stream: each is consumed as the archive +is produced and must not be reused afterwards. This matters for streaming +entries (from [`zlib.ZipEntry.createStream()`][]), which hold an underlying +source such as a file read stream. If the returned stream is destroyed before +it is fully consumed - for example, the destination of a [`pipeline()`][] +fails - it disposes the entry it was serializing and every entry still queued +behind it, destroying their sources so no descriptor leaks. Consume the stream +to the end, or destroy it (directly, through a failed `pipeline()`, or with +`await using`), to guarantee this cleanup; a stream that is neither consumed +nor destroyed cannot release anything. A [`ZipEntry`][] that is never handed to +an archive can be released directly with `Symbol.dispose` / `Symbol.asyncDispose`. + +Throws an [`ERR_ZIP_ARCHIVE_TOO_LARGE`][] error if the archive comment +exceeds 65,535 bytes when encoded as UTF-8. + +```mjs +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), +]; +await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), +); +``` + +```cjs +const { createWriteStream } = require('node:fs'); +const { pipeline } = require('node:stream/promises'); +const { ZipEntry, createZipArchive } = require('node:zlib'); + +async function main() { + const entries = [ + await ZipEntry.create('hello.txt', Buffer.from('Hello, world!')), + await ZipEntry.create('data/', Buffer.alloc(0)), + ]; + await pipeline( + createZipArchive(entries, 'created by node:zlib'), + createWriteStream('archive.zip'), + ); +} +main(); +``` + +Passing `options.baseOffset` produces an archive that is valid immediately +when placed after other content in the same file, without relying on a +reader's self-extracting-archive detection to compensate for the shift: + +```mjs +import { createWriteStream } from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { ZipEntry, createZipArchive } from 'node:zlib'; + +const prefix = Buffer.from('#!/bin/sh\nexit 0\n'); +const entries = [await ZipEntry.create('hello.txt', Buffer.from('Hello, world!'))]; +const out = createWriteStream('self-extracting.zip'); +out.write(prefix); +createZipArchive(entries, { baseOffset: prefix.byteLength }).pipe(out); +``` + +## `zlib.createZipArchiveSync(entries[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `entries` {Iterable} of [`ZipEntry`][]. +* `options` {string|Object} See [`zlib.createZipArchive()`][]. +* Returns: {Iterator} of {Buffer} chunks making up the serialized archive. + +The synchronous version of [`zlib.createZipArchive()`][]. Blocks the +Node.js event loop and further JavaScript execution until the whole +archive (including any deflate passes) has been produced; use only where +synchronous execution is appropriate (for example, short-lived scripts or +startup code), not in code that must stay responsive. `entries` must be a +plain (synchronous) `Iterable` - a streaming entry created with +[`zlib.ZipEntry.createStream()`][] throws when its turn to serialize comes +up, since draining its asynchronous source has no synchronous equivalent. + +As with [`zlib.createZipArchive()`][], the entries are owned by the returned +iterator and must not be reused. If iteration stops early - including the +throw on a streaming entry - the entry that stopped it and every entry still +queued behind it are disposed, releasing any sources they hold. + +## `zlib.zipFiles(files[, options])` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `files` {Iterable} of `[sourcePath, entryName]` string pairs. Any iterable + works — an array, a `Map`, the result of `Object.entries()`, a generator. +* `options` {string|Object} + * `followSymlinks` {boolean} Resolve a symbolic link and archive the file it + points to, rather than storing the link itself. **Default:** `true`. + * `comment` {string} An archive comment; a string `options` is shorthand for + `{ comment: options }`. + * `baseOffset` {number} See [`zlib.createZipArchive()`][]. +* Returns: {stream.Readable} of {Buffer} chunks making up the serialized + archive. + +Builds an archive from files on disk. For each `[sourcePath, entryName]` pair +it reads `sourcePath` and adds an entry named `entryName`, capturing the file's +Unix mode and modification time. A directory becomes a directory entry; a +regular file's contents are streamed in (as a [`zlib.ZipEntry.createStream()`][] +entry) without being buffered in memory. Directory contents are not walked +recursively — list each path you want included. + +When `followSymlinks` is `true` (the default) a symbolic link is resolved and +archived as its target file; when it is `false` the link itself is stored as a +symbolic-link entry whose content is the target path (see +[`zlib.ZipEntry.createSymlink()`][]). + +```mjs +import { zipFiles } from 'node:zlib'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; + +await pipeline( + zipFiles([ + ['/data/report.pdf', 'report.pdf'], + ['/data/notes.txt', 'docs/notes.txt'], + ]), + createWriteStream('archive.zip'), +); +``` + ## `zlib.createZstdCompress([options])` > Stability: 1 - Experimental @@ -1363,6 +2639,44 @@ added: Creates and returns a new [`ZstdDecompress`][] object. +## `zlib.getMaxZipContentSize()` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* Returns: {number} + +The current default ceiling, in bytes, applied by [`zipEntry.content()`][] +when no explicit `maxSize` is given. **Default:** `268435456` (256 MiB). + +## `zlib.setMaxZipContentSize(size)` + + + +> Stability: 1.0 - Early development + +The ZIP archive API is experimental. Using any part of it (this function among +them) emits an experimental warning the first time; merely importing +`node:zlib` does not. + +* `size` {number} + +Sets the default ceiling used by [`zipEntry.content()`][] when no explicit +`maxSize` option is given. This is a guard against zip bombs: an archive +whose central directory declares a member larger than this is rejected +before allocating memory for it. Streaming reads +([`zipEntry.contentIterator()`][], [`zipFile.stream()`][]) are bounded-memory +by design and are not affected by this setting. + ## Convenience methods @@ -2029,11 +3343,22 @@ Create a Zstandard decompression transform. [`Content-Encoding`]: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11 [`DeflateRaw`]: #class-zlibdeflateraw [`Deflate`]: #class-zlibdeflate +[`ERR_INVALID_STATE`]: errors.md#err_invalid_state +[`ERR_ZIP_ARCHIVE_TOO_LARGE`]: errors.md#err_zip_archive_too_large +[`ERR_ZIP_ENTRY_CORRUPT`]: errors.md#err_zip_entry_corrupt +[`ERR_ZIP_ENTRY_NOT_FOUND`]: errors.md#err_zip_entry_not_found +[`ERR_ZIP_ENTRY_TOO_LARGE`]: errors.md#err_zip_entry_too_large +[`ERR_ZIP_INVALID_ARCHIVE`]: errors.md#err_zip_invalid_archive +[`ERR_ZIP_NOT_WRITABLE`]: errors.md#err_zip_not_writable +[`ERR_ZIP_UNSUPPORTED_FEATURE`]: errors.md#err_zip_unsupported_feature [`Gunzip`]: #class-zlibgunzip [`Gzip`]: #class-zlibgzip [`InflateRaw`]: #class-zlibinflateraw [`Inflate`]: #class-zlibinflate [`Unzip`]: #class-zlibunzip +[`ZipBuffer`]: #class-zlibzipbuffer +[`ZipEntry`]: #class-zlibzipentry +[`ZipFile`]: #class-zlibzipfile [`ZlibBase`]: #class-zlibzlibbase [`ZstdCompress`]: #class-zlibzstdcompress [`ZstdDecompress`]: #class-zlibzstddecompress @@ -2041,8 +3366,43 @@ Create a Zstandard decompression transform. [`deflateInit2` and `inflateInit2`]: https://zlib.net/manual.html#Advanced [`node:stream/iter`]: stream_iter.md [`pipeTo()`]: stream_iter.md#pipetosource-transforms-writer-options +[`pipeline()`]: stream.md#streampipelinesource-transforms-destination-callback [`pull()`]: stream_iter.md#pullsource-transforms-options [`stream.Transform`]: stream.md#class-streamtransform +[`zipBuffer.add()`]: #zipbufferaddfilename-data-options +[`zipBuffer.addSync()`]: #zipbufferaddsyncfilename-data-options +[`zipBuffer.comment`]: #zipbuffercomment +[`zipBuffer.toBuffer()`]: #zipbuffertobufferoptions +[`zipBuffer.toBufferSync()`]: #zipbuffertobuffersyncoptions +[`zipEntry.content()`]: #zipentrycontentoptions +[`zipEntry.contentIterator()`]: #zipentrycontentiteratoroptions +[`zipEntry.contentSync()`]: #zipentrycontentsyncoptions +[`zipEntry.isDirectory`]: #zipentryisdirectory +[`zipEntry.isSymlink`]: #zipentryissymlink +[`zipEntry.modified`]: #zipentrymodified +[`zipEntry.nameBuffer`]: #zipentrynamebuffer +[`zipEntry.name`]: #zipentryname +[`zipEntry.rawContent`]: #zipentryrawcontent +[`zipFile.add()`]: #zipfileaddfilename-data-options +[`zipFile.addEntry()`]: #zipfileaddentryentry +[`zipFile.close()`]: #zipfileclose +[`zipFile.closeSync()`]: #zipfileclosesync +[`zipFile.comment`]: #zipfilecomment +[`zipFile.compact()`]: #zipfilecompactcomment +[`zipFile.delete()`]: #zipfiledeletename +[`zipFile.entries()`]: #zipfileentries +[`zipFile.forEach()`]: #zipfileforeachcallback-thisarg +[`zipFile.get()`]: #zipfilegetname +[`zipFile.stream()`]: #zipfilestreamname-options +[`zipFile.values()`]: #zipfilevalues +[`zlib.ZipEntry.create()`]: #static-method-zlibzipentrycreatefilename-data-options +[`zlib.ZipEntry.createStream()`]: #static-method-zlibzipentrycreatestreamfilename-source-options +[`zlib.ZipEntry.createSymlink()`]: #static-method-zlibzipentrycreatesymlinkfilename-target-options +[`zlib.ZipEntry.createSync()`]: #static-method-zlibzipentrycreatesyncfilename-data-options +[`zlib.ZipFile.open()`]: #static-method-zlibzipfileopenfilename-options +[`zlib.createZipArchive()`]: #zlibcreateziparchiveentries-options +[`zlib.createZipArchiveSync()`]: #zlibcreateziparchivesyncentries-options +[`zlib.getMaxZipContentSize()`]: #zlibgetmaxzipcontentsize [convenience methods]: #convenience-methods [zlib documentation]: https://zlib.net/manual.html#Constants [zlib.createGzip example]: #zlib diff --git a/doc/type-map.json b/doc/type-map.json index c4a411139982..c10fff5c404f 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -130,6 +130,9 @@ "WritableStreamDefaultController": "webstreams.html#class-writablestreamdefaultcontroller", "WritableStreamDefaultWriter": "webstreams.html#class-writablestreamdefaultwriter", "X509Certificate": "crypto.html#class-x509certificate", + "ZipBuffer": "zlib.html#class-zlibzipbuffer", + "ZipEntry": "zlib.html#class-zlibzipentry", + "ZipFile": "zlib.html#class-zlibzipfile", "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions" } diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 1b5487849169..93498488ce56 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -2004,4 +2004,11 @@ E('ERR_WORKER_UNSERIALIZABLE_ERROR', 'Serializing an uncaught exception failed', Error); E('ERR_WORKER_UNSUPPORTED_OPERATION', '%s is not supported in workers', TypeError); +E('ERR_ZIP_ARCHIVE_TOO_LARGE', 'ZIP archive structure exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_ENTRY_CORRUPT', 'ZIP entry is corrupt: %s', Error); +E('ERR_ZIP_ENTRY_NOT_FOUND', 'no such entry %j in the archive', Error); +E('ERR_ZIP_ENTRY_TOO_LARGE', 'ZIP entry exceeds the allowed size: %s', RangeError); +E('ERR_ZIP_INVALID_ARCHIVE', 'invalid ZIP archive: %s', Error); +E('ERR_ZIP_NOT_WRITABLE', 'this archive was not opened for writing', TypeError); +E('ERR_ZIP_UNSUPPORTED_FEATURE', 'unsupported ZIP feature: %s', Error); E('ERR_ZSTD_INVALID_PARAM', '%s is not a valid zstd parameter', RangeError); diff --git a/lib/internal/zip.js b/lib/internal/zip.js new file mode 100644 index 000000000000..1889a2eea9da --- /dev/null +++ b/lib/internal/zip.js @@ -0,0 +1,44 @@ +'use strict'; + +// Public entry point for ZIP archive support in `node:zlib`. The +// implementation is split across `internal/zip/`: +// +// constants shared signatures, flags, symbols, and small values +// binary bounds-checked reads and buffer coercion +// content-size the module-global in-memory decompression ceiling +// dos MS-DOS date/time and CP437 legacy name/text decoding +// extra-fields TLV extra-field parsing and building +// headers reader-side header structures and archive-end location +// header-builders writer-side header/record builders +// compression deflate/inflate/zstd plumbing and member decoding +// fs-util fd read/write helpers +// entry ZipEntry +// archive createZipArchive()/zipFiles() serialization +// buffer ZipBuffer +// file ZipFile +// +// This barrel re-exports only the surface `lib/zlib.js` consumes. + +const { ZipEntry } = require('internal/zip/entry'); +const { ZipBuffer } = require('internal/zip/buffer'); +const { ZipFile } = require('internal/zip/file'); +const { + createZipArchive, + createZipArchiveSync, + zipFiles, +} = require('internal/zip/archive'); +const { + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip/content-size'); + +module.exports = { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/archive.js b/lib/internal/zip/archive.js new file mode 100644 index 000000000000..7693ce1297f2 --- /dev/null +++ b/lib/internal/zip/archive.js @@ -0,0 +1,321 @@ +'use strict'; + +// Archive serialization: `createZipArchive()`/`createZipArchiveSync()` and +// the `zipFiles()` on-disk variant, the `generateZipArchive()` generator they +// build on (auto-switching to Zip64 as offsets/counts overflow), and the +// shared archive-option normalizer. + +const { + ArrayPrototypePush, + JSONStringify, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateInteger, + validateObject, + validateString, +} = require('internal/validators'); +const { isUint8Array } = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + EMPTY_BUFFER, + SENTINEL16, + kFinalize, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); + +/** + * `createZipArchive()`/`createZipArchiveSync()` (and the `ZipBuffer` + * `toBuffer()`/`toBufferSync()` methods that forward to them) take a single + * optional `options` argument that doubles as a plain archive comment: a + * string is shorthand for `{ comment: options }`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {{ comment: string | undefined, baseOffset: number }} + */ +function normalizeArchiveOptions(options) { + if (options === undefined) return { comment: undefined, baseOffset: 0 }; + if (typeof options === 'string') return { comment: options, baseOffset: 0 }; + validateObject(options, 'options'); + const { comment, baseOffset = 0 } = options; + // A Buffer comment is an internal convenience (used when round-tripping an + // existing archive) that preserves the original bytes without forcing them + // through a decode/re-encode cycle that would corrupt non-UTF-8 comments. + if (comment !== undefined && !isUint8Array(comment)) { + validateString(comment, 'options.comment'); + } + validateInteger(baseOffset, 'options.baseOffset', 0, NumberMAX_SAFE_INTEGER); + return { comment, baseOffset }; +} + +/** + * Serializes `entries` (a (async) iterable of `ZipEntry`) into a `Readable` + * stream of archive byte chunks, automatically switching to Zip64 structures + * once the entry count or any offset/size exceeds the classic 32-/16-bit + * limits. + * + * `options.baseOffset` shifts every local/central header offset the archive + * records by that many bytes, so the emitted bytes are self-describing even + * when something else is written before them - for example, appending the + * archive after `baseOffset` bytes already written to the same file, rather + * than at its start. + * @param {Iterable | AsyncIterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Readable} + */ +function createZipArchive(entries, options) { + return Readable.from(generateZipArchive(entries, options), { objectMode: false }); +} + +/** + * Creates an archive from files on disk. `files` is an iterable of + * `[sourcePath, entryName]` pairs - an array, a `Map`, the result of + * `Object.entries()`, a generator, and so on. Each entry captures the file's + * Unix mode and modification time; a directory becomes a directory entry and a + * regular file's contents are streamed in without being buffered in memory. + * + * With `options.followSymlinks` (default `true`) a symbolic link is resolved + * and archived as the file it points to; with it `false` the link itself is + * stored as a symlink entry whose content is the target path. + * @param {Iterable<[string, string]>} files + * @param {string | { followSymlinks?: boolean, comment?: string, baseOffset?: number }} [options] + * @returns {import('stream').Readable} + */ +function zipFiles(files, options) { + const followSymlinks = options?.followSymlinks ?? true; + validateBoolean(followSymlinks, 'options.followSymlinks'); + return createZipArchive(fileEntries(files, followSymlinks), options); +} + +// Turn each `[sourcePath, entryName]` pair into a `ZipEntry`, stat-ing the +// source to capture its mode/mtime and picking the symlink/directory/file +// entry shape; the file-backed variant streams contents rather than buffering. +async function* fileEntries(files, followSymlinks) { + for await (const pair of files) { + const sourcePath = pair[0]; + const name = pair[1]; + validateString(sourcePath, 'sourcePath'); + validateString(name, 'name'); + // Following links resolves through to the target (stat); otherwise the + // link itself is inspected (lstat) and stored as a symlink entry. + const stats = followSymlinks ? await fsStatAsync(sourcePath) : await fsLstatAsync(sourcePath); + const options = { __proto__: null, mode: stats.mode & 0o7777, modified: stats.mtime }; + if (stats.isSymbolicLink()) { + yield ZipEntry.createSymlink(name, await fsReadlinkAsync(sourcePath), options); + } else if (stats.isDirectory()) { + const dirName = StringPrototypeEndsWith(name, '/') ? name : `${name}/`; + yield await ZipEntry.create(dirName, EMPTY_BUFFER, options); + } else { + // Open the file ourselves and stream from that exact descriptor instead + // of re-resolving the path with createReadStream(). Re-opening by path + // would let a symlink swapped in after the classification above redirect + // the read - a TOCTOU that defeats followSymlinks:false. O_NOFOLLOW makes + // a final-component symlink fail the open outright when not following; + // the fstat then confirms a regular file, never a FIFO, device, or + // socket (which as a stream source could block or emit unbounded data). + // Metadata comes from that same fstat so it describes the inode we read. + const flags = followSymlinks ? + fs.constants.O_RDONLY : + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const handle = await fs.promises.open(sourcePath, flags); + let ownsHandle = true; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `cannot archive ${JSONStringify(sourcePath)}: not a regular file`); + } + const fileOptions = { + __proto__: null, + mode: stat.mode & 0o7777, + modified: stat.mtime, + }; + // The stream adopts the handle and closes it when it ends or is + // destroyed (autoClose); if the consumer stops before taking the + // entry, the finally destroys the stream, releasing the descriptor. + const stream = fs.createReadStream(undefined, { fd: handle }); + ownsHandle = false; + let handedOff = false; + try { + yield ZipEntry.createStream(name, stream, fileOptions); + handedOff = true; + } finally { + if (!handedOff) stream.destroy(); + } + } finally { + if (ownsHandle) await handle.close(); + } + } + } +} + +// Encode the archive comment (a string, or raw bytes when round-tripping a +// non-UTF-8 comment) and enforce the 16-bit length field (sec. 4.3.16). +function normalizeCommentBuffer(comment) { + if (comment === undefined) return EMPTY_BUFFER; + const buffer = isUint8Array(comment) ? comment : Buffer.from(comment, 'utf8'); + if (buffer.length > SENTINEL16) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE( + 'the archive comment must not exceed 65535 bytes when encoded as UTF-8'); + } + return buffer; +} + +// Dispose the entries still queued behind an interrupted serialization. A +// ZipEntry handed to the archive is owned by it, so when the consumer destroys +// the output stream early - which runs `generateZipArchive`'s `finally` - the +// entries it never reached must be released too (a streaming entry may hold a +// file read stream). The entry currently being serialized is torn down by the +// `for await ... of entry` loop's own return propagation, so only the queue +// behind it is handled here. +// +// A materialized source (an array, `Map`, `Object.entries()`) has already +// created every entry, so each remaining one is pulled and disposed. A lazy +// async source is instead `return()`ed: forcing it to produce every remaining +// entry could open thousands of descriptors just to close them, so it is +// signalled to stop and release its own in-flight resource (see +// `fileEntries()`). +async function disposeQueuedEntries(iterator, isAsync) { + if (isAsync) { + if (typeof iterator.return === 'function') await iterator.return(); + return; + } + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolAsyncDispose] === 'function') await entry[SymbolAsyncDispose](); + } +} + +// Core serializer backing `createZipArchive()`: emits each entry's local header +// + data, then the central directory, then the end-of-central-directory record +// (APPNOTE sec. 4.3.6 archive layout), promoting to Zip64 end records +// (sec. 4.3.14/4.3.15) once any count/offset/size overflows its classic field. +// +// The entries are owned by this generator: it drives the iterator by hand +// (rather than `for await`) so that if the consumer destroys the returned +// stream partway - `Readable.from()` then calls this generator's `return()` - +// the `finally` can dispose every entry that was never fully serialized. A +// synchronous source is stepped without `await` so a pulled-but-not-yet-used +// entry cannot be stranded by a return injected at the `await`. +async function* generateZipArchive(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const isAsync = entries[SymbolAsyncIterator] !== undefined; + const iterator = isAsync ? entries[SymbolAsyncIterator]() : entries[SymbolIterator](); + let completed = false; + try { + while (true) { + const next = isAsync ? await iterator.next() : iterator.next(); + if (next.done) break; + const entry = next.value; + const start = pos; + for await (const chunk of entry) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, entry[kFinalize](start)); + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) await disposeQueuedEntries(iterator, isAsync); + } +} + +/** + * The synchronous counterpart of `createZipArchive()`. `entries` must be a + * plain (synchronous) `Iterable` of entries that don't require an + * asynchronous serialization pass - a streaming entry created with + * `ZipEntry.createStream()` throws when its turn to serialize comes up, the + * same as calling `entry[Symbol.iterator]()` on one directly. Blocks the + * event loop and further JavaScript execution until the whole archive + * (including any deflate passes) has been produced; see + * `zipEntry.contentSync()`. + * @param {Iterable} entries + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @yields {Buffer} + */ +function* createZipArchiveSync(entries, options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const commentBuffer = normalizeCommentBuffer(comment); + const centralHeaders = []; + let pos = baseOffset; + const iterator = entries[SymbolIterator](); + let completed = false; + // As in generateZipArchive(), the entries are owned here: a streaming entry + // throws when serialized synchronously, so on that throw (or an early + // return of this generator) dispose the entry that failed and every entry + // still queued behind it, releasing any sources they hold. + let current = null; + try { + for (let next = iterator.next(); !next.done; next = iterator.next()) { + current = next.value; + const start = pos; + for (const chunk of current) { + yield chunk; + pos += chunk.length; + } + ArrayPrototypePush(centralHeaders, current[kFinalize](start)); + current = null; + } + const centralDirectoryOffset = pos; + for (let i = 0; i < centralHeaders.length; i++) { + const chunk = centralHeaders[i]; + yield chunk; + pos += chunk.length; + } + const centralDirectorySize = pos - centralDirectoryOffset; + const count = centralHeaders.length; + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, commentBuffer); + for (let i = 0; i < trailer.length; i++) yield trailer[i]; + completed = true; + } finally { + if (!completed) { + if (typeof current?.[SymbolDispose] === 'function') current[SymbolDispose](); + for (let next = iterator.next(); !next.done; next = iterator.next()) { + const entry = next.value; + if (typeof entry?.[SymbolDispose] === 'function') entry[SymbolDispose](); + } + } + } +} + +module.exports = { + normalizeArchiveOptions, + createZipArchive, + createZipArchiveSync, + zipFiles, +}; diff --git a/lib/internal/zip/binary.js b/lib/internal/zip/binary.js new file mode 100644 index 000000000000..3aa6b89a5cd3 --- /dev/null +++ b/lib/internal/zip/binary.js @@ -0,0 +1,87 @@ +'use strict'; + +// Low-level binary helpers: bounds-checked archive ranges, safe 64-bit +// integer read/write, and buffer coercion of user input. + +const { + BigInt, + Number, + NumberIsInteger, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { + isAnyArrayBuffer, + isArrayBufferView, + isUint8Array, +} = require('internal/util/types'); +const { Buffer } = require('buffer'); +const { BIGINT_MAX_SAFE_INTEGER } = require('internal/zip/constants'); + +// Reject an [offset, offset + length) slice that escapes the archive buffer +// before it is used to read a record: guards every offset taken from archive +// bytes against corrupt or hostile values. +function validateArchiveRange(buffer, offset, length, what) { + if ( + !NumberIsInteger(offset) || + offset < 0 || + !NumberIsInteger(length) || + length < 0 || + offset + length > buffer.length + ) { + throw new ERR_ZIP_INVALID_ARCHIVE(`${what} is out of bounds`); + } +} + +// Read a little-endian u64 (sizes/offsets, Zip64) that must land in the JS +// safe-integer range; a field past the buffer or beyond that range means a +// corrupt or hostile archive. +function readSafeUint64(buffer, offset) { + if (offset + 8 > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field is out of bounds'); + } + const value = buffer.readBigUInt64LE(offset); + if (value > BIGINT_MAX_SAFE_INTEGER) { + throw new ERR_ZIP_INVALID_ARCHIVE('64-bit field exceeds the safe integer range'); + } + return Number(value); +} + +// Write a JS number as a little-endian u64; the write paths only feed values +// already bounded by the safe-integer range, so no range check is needed here. +function writeSafeUint64(buffer, offset, value) { + buffer.writeBigUInt64LE(BigInt(value), offset); +} + + +// Coerce user-supplied binary input to a Buffer, aliasing the same memory +// (no copy) for a TypedArray/DataView/ArrayBuffer and rejecting anything else. +// internal/crypto/util.js's getArrayBufferOrView() covers similar coercion but +// returns the view unchanged (and accepts strings with an encoding); this +// helper exists because the ZIP code needs an actual Buffer over that memory. +function toBuffer(value, name) { + if (isUint8Array(value)) { + return Buffer.isBuffer(value) ? + value : Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isArrayBufferView(value)) { + return Buffer.from(value.buffer, value.byteOffset, value.byteLength); + } + if (isAnyArrayBuffer(value)) { + return Buffer.from(value); + } + throw new ERR_INVALID_ARG_TYPE( + name, ['Buffer', 'TypedArray', 'DataView', 'ArrayBuffer'], value); +} + +module.exports = { + validateArchiveRange, + readSafeUint64, + writeSafeUint64, + toBuffer, +}; diff --git a/lib/internal/zip/buffer.js b/lib/internal/zip/buffer.js new file mode 100644 index 000000000000..582d8ae799af --- /dev/null +++ b/lib/internal/zip/buffer.js @@ -0,0 +1,186 @@ +'use strict'; + +// `ZipBuffer`: an in-memory, writable view over the entries of an archive +// held in a `Buffer`, serializing the current set back out with +// `toBuffer()`/`toBufferSync()`. + +const { + ArrayPrototypePush, + FunctionPrototypeCall, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_ZIP_ENTRY_NOT_FOUND, + }, +} = require('internal/errors'); +const { + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer } = require('buffer'); +const { toBuffer } = require('internal/zip/binary'); +const { decodeZipText } = require('internal/zip/dos'); +const { findArchiveEnd } = require('internal/zip/headers'); +const { + readArchiveEntries, + ZipEntry, +} = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, + normalizeArchiveOptions, +} = require('internal/zip/archive'); + +/** + * An in-memory view over the entries of a ZIP archive, writable in place: + * entries can be added or removed, and `toBuffer()` serializes the current + * set of entries into a fresh archive. + */ +class ZipBuffer { + #entries = new Map(); + #comment; + + /** + * Parses an existing archive's central directory into an in-memory, + * editable map of entries keyed by name. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + */ + constructor(buffer) { + const buf = toBuffer(buffer, 'buffer'); + // Locate the archive end once; it supplies both the comment and the + // central-directory bounds for the entry walk. + const end = findArchiveEnd(buf); + this.#comment = end.comment; + for (const entry of readArchiveEntries(buf, end)) { + MapPrototypeSet(this.#entries, entry.name, entry); + } + } + get writable() { return true; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + has(name) { + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + get(name) { + validateString(name, 'name'); + const entry = MapPrototypeGet(this.#entries, name); + if (entry === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return entry; + } + /** + * Adds an already-built entry, keyed by its own name (replacing any + * existing entry of that name). + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntry(entry) { + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + MapPrototypeSet(this.#entries, entry.name, entry); + return entry; + } + /** + * Builds an entry from in-memory `data` and adds it (replacing any entry of + * the same name). + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + return this.addEntry(await ZipEntry.create(filename, data, options)); + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop and + * further JavaScript execution until done; see `zipEntry.contentSync()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + return this.addEntry(ZipEntry.createSync(filename, data, options)); + } + /** + * @param {string} name + * @returns {boolean} + */ + delete(name) { + validateString(name, 'name'); + return MapPrototypeDelete(this.#entries, name); + } + clear() { + MapPrototypeClear(this.#entries); + } + keys() { return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + get size() { return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipBuffer'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of MapPrototypeEntries(this.#entries)) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * Serializes the current set of entries into a fresh archive. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Promise} + */ + async toBuffer(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + // Defaulting to the raw #comment bytes (not the decoded string) + // round-trips a non-UTF-8 archive comment unchanged. + for await (const chunk of createZipArchive(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + /** + * The synchronous counterpart of `toBuffer()`. Blocks the event loop and + * further JavaScript execution until the whole archive has been + * serialized; see `zipEntry.contentSync()`. + * @param {string | { comment?: string, baseOffset?: number }} [options] + * @returns {Buffer} + */ + toBufferSync(options) { + const { comment, baseOffset } = normalizeArchiveOptions(options); + const chunks = []; + for (const chunk of createZipArchiveSync(this.values(), { comment: comment ?? this.#comment, baseOffset })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + // Dispose: drop all entries (this view holds no fd of its own). + [SymbolDispose]() { + MapPrototypeClear(this.#entries); + } +} + +module.exports = { + ZipBuffer, +}; diff --git a/lib/internal/zip/compression.js b/lib/internal/zip/compression.js new file mode 100644 index 000000000000..fd9a7373c365 --- /dev/null +++ b/lib/internal/zip/compression.js @@ -0,0 +1,303 @@ +'use strict'; + +// Compression/decompression plumbing over the lazily-required `zlib` facade: +// one-shot (async and sync) and streaming deflate/inflate/zstd helpers, plus +// the member decoders that add method dispatch, size bounding, and CRC-32 +// verification on top. + +const { + JSONStringify, + MathMin, + Promise, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_CORRUPT, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { kMaxLength } = require('buffer'); +const { compose } = require('stream'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + FLAG_ENCRYPTED, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, +} = require('internal/zip/constants'); + +// `internal/zip` is required from `lib/zlib.js`, so it must not require the +// public `zlib` facade at load time (its module.exports is not yet +// populated). Compression is only needed once an entry is actually read or +// written, well after `zlib.js` has finished loading, so a lazy reference is +// enough to break the cycle. +let zlib; +function lazyZlib() { + zlib ??= require('zlib'); + return zlib; +} + +// -- compression plumbing ------------------------------------------------------ + +// The one-shot (async/sync) and streaming helpers below are thin adapters that +// promisify or stream-wrap the lazy `zlib` facade; individually trivial, they +// exist only so the rest of the module never touches `zlib` directly. + +function deflateRawAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().deflateRaw(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function inflateRawAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().inflateRaw(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +// Drive `source` through a zlib transform stream, returning an async-iterable +// stream of its output. `compose()` (pipeline-backed) wires error propagation +// both ways and tears the whole chain down when the consumer errors or stops +// early, so an abandoned iteration cannot leak the pipeline. +function pumpThroughTransform(source, transform) { + return compose(source, transform); +} + +function deflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createDeflateRaw()); +} + +function inflateRawStream(source) { + return pumpThroughTransform(source, lazyZlib().createInflateRaw()); +} + +function zstdCompressAsync(buffer) { + return new Promise((resolve, reject) => { + lazyZlib().zstdCompress(buffer, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdDecompressAsync(buffer, options) { + return new Promise((resolve, reject) => { + lazyZlib().zstdDecompress(buffer, options, (err, result) => { + if (err) reject(err); + else resolve(result); + }); + }); +} + +function zstdCompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdCompress()); +} + +function zstdDecompressStream(source) { + return pumpThroughTransform(source, lazyZlib().createZstdDecompress()); +} + + +function deflateRawSync(buffer) { + return lazyZlib().deflateRawSync(buffer); +} + +function zstdCompressSync(buffer) { + return lazyZlib().zstdCompressSync(buffer); +} + +/** + * @typedef {{ + * name: string, + * flags: number, + * method: number, + * crc32: number, + * uncompressedSize: number, + * }} ZipMemberInfo + */ + +// Shared entry guards for the member decoders below: encryption and +// unsupported compression methods are rejected up front, and when the caller +// bounds the output, a declared size beyond that bound fails before anything +// is decompressed or allocated. +function assertDecodable(info, options) { + if (info.flags & FLAG_ENCRYPTED) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} is encrypted`); + } + if (info.method !== METHOD_STORE && info.method !== METHOD_DEFLATE && info.method !== METHOD_ZSTD) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE( + `entry ${JSONStringify(info.name)} uses compression method ${info.method}`); + } + if (options?.maxSize !== undefined && info.uncompressedSize > options.maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(info.name)} declares ${info.uncompressedSize} bytes, ` + + `exceeding the ${options.maxSize} byte limit`); + } +} + +// Bound one-shot decompression by the declared size, not just the caller's +// limit: a member that decompresses to more than it declares is corrupt by +// definition, so there is no reason to materialize more than `declared + 1` +// bytes (the +1 makes an overrun detectable) no matter how generous `maxSize` +// is. This keeps a tiny archive from forcing a `maxSize`-sized allocation. +function outputCap(info, options) { + return MathMin( + info.uncompressedSize + 1, options?.maxSize ?? kMaxLength, kMaxLength); +} + +// Map a one-shot decompression failure to a corrupt-entry error. +// `assertDecodable()` has already ensured `declared <= maxSize`, so hitting +// the output cap always means the stream produced more than the member +// declared. +function rethrowDecodeFailure(err, info, method) { + if (err?.code === 'ERR_BUFFER_TOO_LARGE') { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} ` + + `${method === METHOD_DEFLATE ? 'inflates' : 'decompresses'} beyond its ` + + `declared size of ${info.uncompressedSize} bytes`); + } + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed to ` + + `${method === METHOD_DEFLATE ? 'inflate' : 'decompress'}: ${err.message}`); +} + +// Enforce the declared size and (unless opted out) the CRC-32 on a fully +// decoded member. +function checkDecoded(data, info, verify) { + if (data.length !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} produced ${data.length} bytes, expected ` + + `${info.uncompressedSize}`); + } + if (verify && crc32Native(data, 0) !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member's compressed byte stream: rejects encrypted entries and + * unsupported compression methods, inflates method 8 or decompresses method + * 93 (Zstandard), enforces the declared uncompressed size and verifies + * CRC-32 (on by default). + * @param {AsyncIterable} source + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @yields {Buffer} + */ +async function* decodeMemberStream(source, info, options) { + assertDecodable(info, options); + const verify = options?.verify !== false; + let produced = 0; + let state = 0; + const decoded = info.method === METHOD_DEFLATE ? inflateRawStream(source) : + info.method === METHOD_ZSTD ? zstdDecompressStream(source) : source; + for await (const chunk of decoded) { + produced += chunk.length; + if (produced > info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} inflates beyond its declared size of ` + + `${info.uncompressedSize} bytes`); + } + if (verify) state = crc32Native(chunk, state); + yield chunk; + } + if (produced !== info.uncompressedSize) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} is truncated: got ${produced} of ` + + `${info.uncompressedSize} bytes`); + } + if (verify && state !== info.crc32) { + throw new ERR_ZIP_ENTRY_CORRUPT( + `entry ${JSONStringify(info.name)} failed CRC-32 verification`); + } +} + +/** + * Decodes one member held completely in `compressed`, in one shot: the same + * guards, declared-size bounding, and CRC-32 verification as + * `decodeMemberStream()`, returning the whole decoded member. For the store + * method the input buffer itself is returned - callers that must hand out + * caller-owned memory copy it (see `zipEntry.content()`). + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ +async function decodeMemberAsync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = await inflateRawAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = await zstdDecompressAsync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +/** + * The synchronous counterpart of `decodeMemberAsync()`. There is no public + * synchronous incremental inflate API, so - unlike the streaming path - + * `compressed` must already be the member's complete compressed byte + * stream, and the whole result is produced (and verified) in one call + * rather than yielded incrementally. + * @param {Buffer} compressed + * @param {ZipMemberInfo} info + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ +function decodeMemberSync(compressed, info, options) { + assertDecodable(info, options); + const cap = outputCap(info, options); + let data; + if (info.method === METHOD_DEFLATE) { + try { + data = lazyZlib().inflateRawSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_DEFLATE); + } + } else if (info.method === METHOD_ZSTD) { + try { + data = lazyZlib().zstdDecompressSync(compressed, { maxOutputLength: cap }); + } catch (err) { + rethrowDecodeFailure(err, info, METHOD_ZSTD); + } + } else { + data = compressed; + } + checkDecoded(data, info, options?.verify !== false); + return data; +} + +module.exports = { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +}; diff --git a/lib/internal/zip/constants.js b/lib/internal/zip/constants.js new file mode 100644 index 000000000000..2fe9d684b7f4 --- /dev/null +++ b/lib/internal/zip/constants.js @@ -0,0 +1,109 @@ +'use strict'; + +// Shared constants, symbols, and tiny shared values for the ZIP +// implementation. This module is a leaf: it requires nothing from the rest of +// `internal/zip`, so every other zip module can require it without cycles. + +const { + BigInt, + NumberMAX_SAFE_INTEGER, + Symbol, +} = primordials; + +const { FastBuffer } = require('internal/buffer'); + +const EMPTY_BUFFER = new FastBuffer(); +const BIGINT_MAX_SAFE_INTEGER = BigInt(NumberMAX_SAFE_INTEGER); + +// ZIP record signatures (APPNOTE.TXT, PKWARE Inc.) +const SIG_LOCAL_FILE_HEADER = 0x04034b50; // sec. 4.3.7 +const SIG_DATA_DESCRIPTOR = 0x08074b50; // sec. 4.3.9 +const SIG_CENTRAL_FILE_HEADER = 0x02014b50; // sec. 4.3.12 +const SIG_ZIP64_EOCD_RECORD = 0x06064b50; // sec. 4.3.14 +const SIG_ZIP64_EOCD_LOCATOR = 0x07064b50; // sec. 4.3.15 +const SIG_EOCD = 0x06054b50; // sec. 4.3.16 + +const MADE_BY_UNIX = 3; // sec. 4.4.2 +const ZIP64_EXTRA_ID = 0x0001; // sec. 4.5.3 + +const SENTINEL16 = 0xffff; +const SENTINEL32 = 0xffffffff; + +const FLAG_ENCRYPTED = 0x0001; // sec. 4.4.4 bit 0 +const FLAG_DATA_DESCRIPTOR = 0x0008; // bit 3 +const FLAG_UTF8 = 0x0800; // bit 11: name/comment are UTF-8 (EFS) + +const METHOD_STORE = 0; // sec. 4.4.5 +const METHOD_DEFLATE = 8; // sec. 4.4.5 +const METHOD_ZSTD = 93; // sec. 4.4.5 + +const VERSION_DEFAULT = 20; // 2.0: deflate + directories (sec. 4.4.3) +const VERSION_ZIP64 = 45; // 4.5: Zip64 structures +const VERSION_ZSTD = 63; // 6.3: Zstandard compression (method 93) + +// The Zip64 EOCD record may carry an extensible data sector of arbitrary +// length between its fixed part and the locator, so it can start before any +// fixed-size tail read. When the locator points further back than the bytes +// at hand, callers re-read from the recorded offset - but only within this +// bound, so a hostile locator cannot demand an unbounded allocation. +const ZIP64_EOCD_MAX_LENGTH = 56 + 1024 * 1024; + +const S_IFREG = 0o100000; // Unix mode type bits: regular file +const S_IFDIR = 0o040000; // Unix mode type bits: directory +const S_IFLNK = 0o120000; // Unix mode type bits: symbolic link +const S_IFMT = 0o170000; // Unix mode type mask + +// Extra-field header IDs we consult on read (sec. 4.5). +const EXTRA_ID_NTFS = 0x000a; // NTFS times (100 ns since 1601) +const EXTRA_ID_EXT_TIMESTAMP = 0x5455; // Info-ZIP extended timestamp ("UT") +const EXTRA_ID_UNIX_OLD = 0x5855; // Info-ZIP Unix, original ("UX") +const EXTRA_ID_UNICODE_PATH = 0x7075; // Info-ZIP Unicode Path ("up") + +// Passed between `ZipEntry` and the archive writers/`ZipFile`: `kFinalize` +// asks an entry for its central-directory header at a known local offset; +// `kPromote` rebinds a just-serialized streaming entry to its on-disk copy. +const kFinalize = Symbol('kFinalize'); +const kPromote = Symbol('kPromote'); + +// The chunk size for reading a file-backed member's compressed bytes. +const READ_CHUNK_SIZE = 4 * 1024 * 1024; +// EOCD + max comment + Zip64 locator + Zip64 record + slack for an +// extensible data sector: the fixed-size tail `ZipFile.open()` reads first. +const TAIL_LENGTH = 22 + SENTINEL16 + 20 + 56 + 4096; + +module.exports = { + EMPTY_BUFFER, + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_ENCRYPTED, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, + ZIP64_EOCD_MAX_LENGTH, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, + EXTRA_ID_UNICODE_PATH, + kFinalize, + kPromote, + READ_CHUNK_SIZE, + TAIL_LENGTH, +}; diff --git a/lib/internal/zip/content-size.js b/lib/internal/zip/content-size.js new file mode 100644 index 000000000000..0693e86f1568 --- /dev/null +++ b/lib/internal/zip/content-size.js @@ -0,0 +1,41 @@ +'use strict'; + +// The module-global default ceiling on in-memory member decompression, and +// its public getter/setter. Kept in its own module so every read path sees +// the one mutable value. + +const { validateInteger } = require('internal/validators'); + +// A default ceiling on the uncompressed size that the buffering read paths +// (`ZipEntry.prototype.content()`, and therefore `ZipBuffer`/`ZipFile` +// `get()`) will materialize in memory when the caller does not pass an +// explicit `maxSize`. An archive whose central directory declares a member +// larger than this is rejected before any large allocation happens. Callers +// that need larger members can either pass a per-call `maxSize` or raise the +// module default with `setMaxZipContentSize()`. The streaming read paths +// (`contentIterator()`, `ZipFile.prototype.stream()`) are bounded-memory by +// design and are not subject to this default. +const DEFAULT_MAX_ZIP_CONTENT_SIZE = 256 * 1024 * 1024; // 256 MiB +let maxZipContentSize = DEFAULT_MAX_ZIP_CONTENT_SIZE; + +/** + * @returns {number} + */ +function getMaxZipContentSize() { + return maxZipContentSize; +} + +/** + * @param {number} size + * @returns {void} + */ +function setMaxZipContentSize(size) { + validateInteger(size, 'size', 0); + maxZipContentSize = size; +} + +module.exports = { + DEFAULT_MAX_ZIP_CONTENT_SIZE, + getMaxZipContentSize, + setMaxZipContentSize, +}; diff --git a/lib/internal/zip/dos.js b/lib/internal/zip/dos.js new file mode 100644 index 000000000000..d3a41323b87e --- /dev/null +++ b/lib/internal/zip/dos.js @@ -0,0 +1,138 @@ +'use strict'; + +// DOS/IBM legacy decoding: MS-DOS date/time fields (sec. 4.4.6) and the +// historical Code Page 437 name/comment encoding, plus the modern +// UTF-8/Unicode-Path handling layered on top of them. + +const { + Date, + NumberIsNaN, + StringFromCharCode, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_VALUE, + }, +} = require('internal/errors'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { isUtf8 } = internalBinding('buffer'); +const { + FLAG_UTF8, + EXTRA_ID_UNICODE_PATH, +} = require('internal/zip/constants'); +const { forEachExtraField } = require('internal/zip/extra-fields'); + +// DOS date/time (sec. 4.4.6): local time by convention. +// time: bits 0-4 seconds/2, 5-10 minutes, 11-15 hours +// date: bits 0-4 day, 5-8 month, 9-15 years since 1980 +function decodeDosDateTime(time, date) { + // A zeroed/absent date field has month 0 and day 0, both invalid; the DOS + // epoch is 1980-01-01. Month/day 0 are treated as 1 so a zero field decodes + // to 1980-01-01 (and re-encodes to the same value). + return new Date( + ((date >>> 9) & 0x7f) + 1980, + ((date >>> 5) & 0x0f || 1) - 1, + (date & 0x1f) || 1, + (time >>> 11) & 0x1f, + (time >>> 5) & 0x3f, + (time & 0x1f) * 2, + ); +} + +// Encode a Date into packed MS-DOS time/date fields (sec. 4.4.6), clamping to +// the representable range 1980-01-01 .. 2107-12-31. +function encodeDosDateTime(value) { + const year = value.getFullYear(); + if (NumberIsNaN(year)) { + throw new ERR_INVALID_ARG_VALUE('modified', value, 'must be a valid Date'); + } + if (year < 1980) return { time: 0, date: (1 << 5) | 1 }; // Clamp to 1980-01-01 00:00:00 + if (year > 2107) { + // Clamp to 2107-12-31 23:59:58 + return { + time: (23 << 11) | (59 << 5) | 29, + date: (127 << 9) | (12 << 5) | 31, + }; + } + const date = + ((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate(); + const time = + (value.getHours() << 11) | + (value.getMinutes() << 5) | + (value.getSeconds() >>> 1); + return { time, date }; +} + +// Code Page 437 high half (0x80-0xFF) -> Unicode. Names without the UTF-8 +// language-encoding flag (bit 11) are historically CP437, which every real +// tool (Info-ZIP, Windows Explorer) assumes; bytes 0x00-0x7F are ASCII. +const CP437_HIGH = [ + 0x00c7, 0x00fc, 0x00e9, 0x00e2, 0x00e4, 0x00e0, 0x00e5, 0x00e7, + 0x00ea, 0x00eb, 0x00e8, 0x00ef, 0x00ee, 0x00ec, 0x00c4, 0x00c5, + 0x00c9, 0x00e6, 0x00c6, 0x00f4, 0x00f6, 0x00f2, 0x00fb, 0x00f9, + 0x00ff, 0x00d6, 0x00dc, 0x00a2, 0x00a3, 0x00a5, 0x20a7, 0x0192, + 0x00e1, 0x00ed, 0x00f3, 0x00fa, 0x00f1, 0x00d1, 0x00aa, 0x00ba, + 0x00bf, 0x2310, 0x00ac, 0x00bd, 0x00bc, 0x00a1, 0x00ab, 0x00bb, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, + 0x2555, 0x2563, 0x2551, 0x2557, 0x255d, 0x255c, 0x255b, 0x2510, + 0x2514, 0x2534, 0x252c, 0x251c, 0x2500, 0x253c, 0x255e, 0x255f, + 0x255a, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256c, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256b, + 0x256a, 0x2518, 0x250c, 0x2588, 0x2584, 0x258c, 0x2590, 0x2580, + 0x03b1, 0x00df, 0x0393, 0x03c0, 0x03a3, 0x03c3, 0x00b5, 0x03c4, + 0x03a6, 0x0398, 0x03a9, 0x03b4, 0x221e, 0x03c6, 0x03b5, 0x2229, + 0x2261, 0x00b1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00f7, 0x2248, + 0x00b0, 0x2219, 0x00b7, 0x221a, 0x207f, 0x00b2, 0x25a0, 0x00a0, +]; + +// Decode a CP437-encoded name/comment to a JS string via the table above. +function decodeCp437(buffer) { + let out = ''; + for (let i = 0; i < buffer.length; i++) { + const b = buffer[i]; + out += StringFromCharCode(b < 0x80 ? b : CP437_HIGH[b - 0x80]); + } + return out; +} + +// The UTF-8 name from an Info-ZIP Unicode Path extra field (sec. 4.6.9), but +// only when its version is 1 and its CRC-32 matches the standard-field name +// bytes (so a stale extra left over from a rename is ignored). Otherwise null. +function unicodePathName(extra, standardNameBuffer) { + let result = null; + forEachExtraField(extra, (id, body) => { + if (result !== null || id !== EXTRA_ID_UNICODE_PATH || body.length < 5) return; + if (body[0] !== 1) return; + // The crc32 binding returns an unsigned uint32, directly comparable. + if (body.readUInt32LE(1) !== crc32Native(standardNameBuffer, 0)) return; + result = body.toString('utf8', 5); + }); + return result; +} + +// Decode an entry name: prefer a valid Unicode Path extra field, else the +// UTF-8/CP437 heuristic below. +function decodeZipName(nameBuffer, flags, extra) { + if (extra?.length) { + const unicode = unicodePathName(extra, nameBuffer); + if (unicode !== null) return unicode; + } + return decodeZipText(nameBuffer, flags); +} + +// Decode a name/comment: UTF-8 when bit 11 says so, or when the bytes are +// valid UTF-8 anyway - plenty of real tools (pre-JDK7 java.util.zip among +// them) wrote UTF-8 names without ever setting the flag. Only genuinely +// non-UTF-8 bytes take the historical CP437 default. +function decodeZipText(buffer, flags) { + if ((flags & FLAG_UTF8) || isUtf8(buffer)) return buffer.toString('utf8'); + return decodeCp437(buffer); +} + +module.exports = { + decodeDosDateTime, + encodeDosDateTime, + decodeZipName, + decodeZipText, +}; diff --git a/lib/internal/zip/entry.js b/lib/internal/zip/entry.js new file mode 100644 index 000000000000..4eab161db9b2 --- /dev/null +++ b/lib/internal/zip/entry.js @@ -0,0 +1,962 @@ +'use strict'; + +// `ZipEntry`: a single archive member. Reads (buffered and streaming), +// (de)serialization, and the `create()`/`createSync()`/`createStream()`/ +// `createSymlink()` builders, plus the `createEntryMeta()` helper that +// normalizes their options into the internal metadata record. + +const { + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + Date, + DateNow, + JSONStringify, + MathFloor, + MathMin, + NumberMAX_SAFE_INTEGER, + StringPrototypeEndsWith, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_STATE, + ERR_ZIP_ENTRY_TOO_LARGE, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + validateInteger, + validateString, + validateUint32, +} = require('internal/validators'); +const { + isDate, + isUint8Array, +} = require('internal/util/types'); +const { Buffer, kMaxLength } = require('buffer'); +const { crc32: crc32Native } = internalBinding('zlib'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SENTINEL16, + FLAG_DATA_DESCRIPTOR, + FLAG_UTF8, + MADE_BY_UNIX, + METHOD_STORE, + METHOD_DEFLATE, + METHOD_ZSTD, + S_IFREG, + S_IFDIR, + S_IFLNK, + S_IFMT, + READ_CHUNK_SIZE, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + toBuffer, + validateArchiveRange, +} = require('internal/zip/binary'); +const { + extraFieldMtime, + stripZip64Extra, +} = require('internal/zip/extra-fields'); +const { + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, +} = require('internal/zip/headers'); +const { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, +} = require('internal/zip/header-builders'); +const { + deflateRawAsync, + deflateRawSync, + zstdCompressAsync, + zstdCompressSync, + deflateRawStream, + zstdCompressStream, + decodeMemberStream, + decodeMemberAsync, + decodeMemberSync, +} = require('internal/zip/compression'); +const { + readFdFully, + readFdFullySync, +} = require('internal/zip/fs-util'); +const { getMaxZipContentSize } = require('internal/zip/content-size'); + +// The whole (UTC) second to record in an extended-timestamp extra field when +// `mtimeMs` cannot be represented exactly by the 2-second-resolution, +// local-time DOS date/time fields (sub-second parts and odd seconds alike), +// or null when the DOS fields suffice or the value does not fit the extra +// field's signed 32-bit Unix-seconds range (through 2038). One rule, shared +// by createEntryMeta() and #finalizeMeta() so the two cannot drift. +function extendedMtimeSeconds(mtimeMs) { + const seconds = MathFloor(mtimeMs / 1000); + return (mtimeMs % 2000 !== 0 && seconds >= -2147483648 && seconds <= 2147483647) ? + seconds : null; +} + +// Normalize the public builder options into the internal metadata record: +// name/comment bytes, the UTF-8 flag, the Unix mode packed into the external +// attributes (sec. 4.4.15), and the DOS/extended-timestamp fields. +function createEntryMeta(filename, options) { + validateString(filename, 'filename'); + const name = Buffer.from(filename, 'utf8'); + if (name.length === 0) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'must not be empty'); + } + if (name.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry name must not exceed 65535 bytes when encoded as UTF-8'); + } + let comment = EMPTY_BUFFER; + if (options?.comment !== undefined) { + validateString(options.comment, 'options.comment'); + comment = Buffer.from(options.comment, 'utf8'); + if (comment.length > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry comment must not exceed 65535 bytes when encoded as UTF-8'); + } + } + const isSymlink = options?.symlink === true; + const isDirectory = !isSymlink && StringPrototypeEndsWith(filename, '/'); + const mode = options?.mode ?? (isSymlink ? 0o777 : isDirectory ? 0o755 : 0o644); + validateUint32(mode, 'options.mode'); + // Default to the current time at the DOS fields' 2-second resolution, so a + // default entry needs no extended-timestamp extra field (see below). + const modified = options?.modified ?? new Date(MathFloor(DateNow() / 2000) * 2000); + if (!isDate(modified)) { + throw new ERR_INVALID_ARG_TYPE('options.modified', 'Date', modified); + } + if (options?.method !== undefined && + options.method !== 'deflate' && options.method !== 'store' && options.method !== 'zstd') { + throw new ERR_INVALID_ARG_VALUE( + 'options.method', options.method, "must be 'deflate', 'store', or 'zstd'"); + } + const typeBits = isSymlink ? S_IFLNK : isDirectory ? S_IFDIR : S_IFREG; + const unixAttrs = (typeBits | (mode & 0o7777)) & SENTINEL16; + const external = ((unixAttrs << 16) | (isDirectory ? 0x10 : 0)) >>> 0; + // Record the whole (UTC) second in an extended-timestamp extra field when + // the DOS fields cannot represent the time exactly; see + // extendedMtimeSeconds(). + const extendedMtime = extendedMtimeSeconds(modified.getTime()); + return { + name, + comment, + extra: EMPTY_BUFFER, + flags: FLAG_UTF8, + method: 0, + crc: 0, + compressedSize: 0, + uncompressedSize: 0, + modified, + extendedMtime, + external, + internal: 0, + madeBy: MADE_BY_UNIX, + pending: true, + }; +} + +/** + * A single file or directory inside a ZIP archive: reading, writing, and + * (de)serializing one archive member. + */ +class ZipEntry { + #central; + #local; + #content; + #source = null; + #meta = null; + #serialized = false; + // When #fd is non-null the entry is "file-backed": it holds no content + // buffer, only a descriptor *handle* ({ fd, closed }) shared with the + // owning ZipFile plus the local-header offset, and reads its compressed + // bytes from disk on demand (see #compressedBytes/#rawChunks). The shared + // handle lets close() invalidate every outstanding entry at once, so a + // read never falls through to a bare (possibly reused) fd number. + // #contentOffset caches the resolved start of the compressed data (a + // number, not a buffer) once the local header has been read. + #fd = null; + #localOffset = -1; + #contentOffset = -1; + + /** + * @private + */ + constructor(central, local, content, fd = null, localOffset = -1) { + this.#central = central; + this.#local = local; + this.#content = content; + this.#fd = fd; + this.#localOffset = localOffset; + } + + // Whether the content is stored in compressed form, whatever the method. + get compressed() { return this.method !== METHOD_STORE; } + get rawContent() { return this.#content; } + get method() { + return this.#meta ? this.#meta.method : this.#central.compressionMethod; + } + get flags() { + return this.#meta ? this.#meta.flags : (this.#local ?? this.#central).flags; + } + get crc32() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.crc; + } + return this.#central.crc32; + } + get name() { + // The central directory is authoritative; a mismatched local-header name + // is deliberately ignored (defends against parser-confusion attacks). + // Both branches use the same decoding (Unicode Path extra, then + // UTF-8/CP437), so serialization - which snapshots the raw bytes into + // #meta - never changes what this getter reports. + return this.#meta ? + decodeZipName(this.#meta.name, this.#meta.flags, this.#meta.extra) : + this.#central.fileName; + } + get nameBuffer() { + return this.#meta ? this.#meta.name : this.#central.fileNameBuffer; + } + get comment() { + return this.#meta ? + decodeZipText(this.#meta.comment, this.#meta.flags) : + this.#central.fileComment; + } + get size() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.uncompressedSize; + } + return this.#central.uncompressedSize; + } + get compressedSize() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta.compressedSize; + } + return this.#central.compressedSize; + } + get modified() { + if (this.#meta) return this.#meta.modified; + // Prefer an extra-field timestamp (absolute, higher-resolution) over the + // coarse local-time DOS date/time fields when a foreign archive carries + // one; consult both the central and local headers. A file-backed entry + // starts out with only its central header, but some tools (7-Zip, for + // one) write their high-fidelity timestamp only into the local header - + // resolve it lazily (a small, one-time positioned read) rather than + // silently reporting a coarser time than the archive carries, and fall + // back to the central data when the local header cannot be read. + if (this.#fd !== null && this.#local === null) { + try { + this.#resolveLocalHeaderSync(); + } catch { + // A malformed local header fails loudly on the content read paths; + // for metadata, the central directory alone has to do. + } + } + return extraFieldMtime(this.#central.extraField, this.#local?.extraField) ?? + this.#central.lastModified; + } + get mode() { + // The external attributes' high 16 bits hold Unix permissions only when + // the entry was made by a Unix host (sec. 4.4.2/4.4.15) - the same rule + // as `CentralFileHeader.prototype.mode`, which the non-meta branch + // defers to. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX ? + (this.#meta.external >>> 16) & 0o7777 : 0; + } + return this.#central.mode; + } + get isSymlink() { + // Derived from the made-by host and the external attributes' Unix type + // bits in both branches, so it survives serialization (which preserves + // both verbatim); this also makes a fresh `createSymlink()` entry report + // itself as one. + if (this.#meta) { + return this.#meta.madeBy === MADE_BY_UNIX && + ((this.#meta.external >>> 16) & S_IFMT) === S_IFLNK; + } + return this.#central.isSymlink; + } + get isFile() { return !this.isDirectory && !this.isSymlink; } + get isDirectory() { return StringPrototypeEndsWith(this.name, '/'); } + + // Guard: reject metadata/content reads on a write-streaming entry whose + // sizes and CRC are not yet known (still pending serialization). + #assertNotPending() { + if (this.#meta?.pending) { + throw new ERR_INVALID_STATE( + 'this streaming entry has not finished serializing yet'); + } + } + + // Snapshot this (parsed) entry's central-directory data into a + // re-serializable #meta record, memoized. Needed so a round-tripped entry + // can be re-emitted from stable, extra-field-aware values rather than raw + // header bytes; clears the data-descriptor flag (see below). + #finalizeMeta() { + if (this.#meta) { + this.#assertNotPending(); + return this.#meta; + } + const central = this.#central; + // Descriptor entries (bit 3) are re-emitted with known sizes/CRC and bit + // 3 cleared: full re-serialization emits a fresh local header from this + // same record, so it never reproduces a bit-3 local header without a + // data descriptor. (This invariant does NOT hold for `ZipFile`'s + // in-place central-directory rewrite, which leaves local headers on disk + // untouched - that path re-asserts bit 3 via `[kFinalize]`; see there.) + // Sizes come from the central directory + // (Zip64-aware); Zip64 extras are regenerated as needed, and all other + // extra-field records (Unicode Path, NTFS/UT timestamps, ...) are + // preserved so a re-serialized entry keeps its name encoding and + // timestamps. + const extra = stripZip64Extra(central.extraField); + // Snapshot the resolved (extra-field-aware) time, not the raw DOS field, + // so serialization does not degrade what `modified` reports. When that + // time cannot be represented exactly in the DOS fields and no preserved + // extra record carries it, record it in an extended-timestamp extra + // (see extendedMtimeSeconds(); same rule as createEntryMeta()). + const modified = this.modified; + const extendedMtime = extraFieldMtime(extra) === null ? + extendedMtimeSeconds(modified.getTime()) : null; + const meta = { + name: central.fileNameBuffer, + comment: central.fileCommentBuffer, + extra, + flags: central.flags & ~FLAG_DATA_DESCRIPTOR, + method: central.compressionMethod, + crc: central.crc32, + compressedSize: central.compressedSize, + uncompressedSize: central.uncompressedSize, + modified, + extendedMtime, + external: central.externalFileAttributes, + internal: central.internalFileAttributes, + // Preserve the creator's host byte (sec. 4.4.2): the external + // attributes are only interpretable relative to it. + madeBy: central.version >>> 8, + pending: false, + }; + this.#meta = meta; + return meta; + } + + // The live numeric descriptor for a file-backed entry, or a clean state + // error if the ZipFile that owns it has since been closed - never a raw + // (possibly OS-reused) fd number. Returns null for an in-memory entry. + // A file-backed entry holds a descriptor *handle* ({ fd, closed }) shared + // with its ZipFile, not a bare fd, so close() is observable here. + #liveDescriptor() { + const handle = this.#fd; + if (handle === null) return null; + if (handle.closed) { + throw new ERR_INVALID_STATE( + 'cannot read a ZipEntry after its backing ZipFile has been closed'); + } + return handle.fd; + } + + // Read (and cache) this file-backed entry's local file header - whose + // length (fixed 30 bytes plus variable name/extra fields) is only known + // from the file itself, not from the central directory. Resolving it also + // yields the offset where the compressed data begins, and gives the + // `modified` getter access to a local-header-only timestamp extra. + async #resolveLocalHeader() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + await readFdFully(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // Sync counterpart of #resolveLocalHeader(). + #resolveLocalHeaderSync() { + if (this.#local !== null) return this.#local; + const fd = this.#liveDescriptor(); + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, this.#localOffset); + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(this.name)} has an invalid local file header`); + } + const length = LocalFileHeader.length(fixed, 0); + const full = Buffer.allocUnsafe(length); + fixed.copy(full, 0); + if (length > 30) { + readFdFullySync(fd, full.subarray(30), this.#localOffset + 30); + } + this.#local = new LocalFileHeader(full, 0); + this.#contentOffset = this.#localOffset + length; + return this.#local; + } + // The offset where this file-backed entry's compressed data begins, reading + // the local header first if that has not been resolved yet. + async #resolveContentOffset() { + if (this.#contentOffset < 0) await this.#resolveLocalHeader(); + return this.#contentOffset; + } + // Sync counterpart of #resolveContentOffset(). + #resolveContentOffsetSync() { + if (this.#contentOffset < 0) this.#resolveLocalHeaderSync(); + return this.#contentOffset; + } + // The in-memory raw bytes, or a clean state error when there are none - a + // write-streaming entry (`createStream()`) has no readable content until it + // has been serialized into a backing archive (after which `addEntry()` + // promotes it to file-backed; see [kPromote]). + #inMemoryCompressed() { + if (this.#content === null) { + throw new ERR_INVALID_STATE( + 'the content of a streaming entry is not available for reading'); + } + return this.#content; + } + // The entry's raw (still-compressed) bytes. For an in-memory entry this is + // the entry's retained buffer - shared memory, NOT a copy: it may alias + // the source archive (`ZipEntry.read()`) or the caller's original `data` + // (`create()` when storing). For a file-backed entry it is freshly read + // from disk and caller-owned. Paths that hand these bytes out undecoded + // (the store method) must copy the in-memory case; see `content()`. + async #compressedBytes() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + // A member at or beyond the maximum Buffer length cannot be materialized + // in one allocation; stream it instead. (kMaxLength equals the safe + // integer ceiling on 64-bit, so a larger size fails to parse anyway.) + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = await this.#resolveContentOffset(); + const compressed = Buffer.allocUnsafe(size); + await readFdFully(this.#liveDescriptor(), compressed, start); + return compressed; + } + // Sync counterpart of #compressedBytes(). + #compressedBytesSync() { + if (this.#fd === null) return this.#inMemoryCompressed(); + const size = this.compressedSize; + if (size >= kMaxLength) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} is too large to buffer ` + + `(${size} compressed bytes); use contentIterator() instead`); + } + const start = this.#resolveContentOffsetSync(); + const compressed = Buffer.allocUnsafe(size); + readFdFullySync(this.#liveDescriptor(), compressed, start); + return compressed; + } + // The entry's raw compressed bytes as a bounded-memory chunk stream, read + // straight from disk (file-backed entries only). Nothing is retained. + async *#rawChunks() { + this.#liveDescriptor(); + let pos = await this.#resolveContentOffset(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + await readFdFully(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + // Sync counterpart of #rawChunks(). + *#rawChunksSync() { + this.#liveDescriptor(); + let pos = this.#resolveContentOffsetSync(); + let remaining = this.compressedSize; + while (remaining > 0) { + const take = MathMin(READ_CHUNK_SIZE, remaining); + const chunk = Buffer.allocUnsafe(take); + // Re-check per chunk: the ZipFile may be closed mid-stream. + readFdFullySync(this.#liveDescriptor(), chunk, pos); + pos += take; + remaining -= take; + yield chunk; + } + } + + /** + * Reads, decompresses, and (by default) CRC-32-verifies the whole entry + * into a single `Buffer`, enforcing the declared-size and `maxSize` limits. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async content(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = await this.#compressedBytes(); + const data = await decodeMemberAsync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // (the entry's retained buffer, see #compressedBytes()) so the result is + // caller-owned on every path. + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + /** + * The synchronous counterpart of `content()`. Blocks the event loop and + * further JavaScript execution until the whole entry has been read and, if + * applicable, inflated - use only where synchronous I/O is appropriate + * (for example, short-lived scripts or startup code), not in code that + * must stay responsive. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Buffer} + */ + contentSync(options) { + const declared = this.size; + const maxSize = options?.maxSize ?? getMaxZipContentSize(); + if (declared > maxSize) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + `entry ${JSONStringify(this.name)} declares ${declared} bytes, ` + + `exceeding the ${maxSize} byte limit`); + } + const compressed = this.#compressedBytesSync(); + const data = decodeMemberSync(compressed, { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: declared, + }, { verify: options?.verify, maxSize }); + // `data === compressed` only on the store path; copy the in-memory case + // so the result is caller-owned on every path (see content()). + return data === compressed && this.#fd === null ? Buffer.from(data) : data; + } + + // The raw (still-compressed) bytes as an async iterable, without buffering + // the whole member: straight from disk for a file-backed entry, or the + // single in-memory buffer otherwise. Throws synchronously for a pending + // write-streaming entry, whose content is not yet available for reading. + #rawSource() { + if (this.#fd !== null) return this.#rawChunks(); + const content = this.#inMemoryCompressed(); + return (async function* () { + if (content.length) yield content; + })(); + } + + /** + * Yields the entry's decompressed content as a bounded-memory async + * iterator of `Buffer` chunks, decompressing on the way and (by default) + * verifying CRC-32. For a file-backed entry (from `ZipFile.get()`) the + * compressed bytes are read from disk as they are consumed and nothing is + * retained. + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {AsyncGenerator} + */ + contentIterator(options) { + // No default maxSize here, unlike content(): streaming is the + // bounded-memory path for arbitrarily large members, so imposing content()'s + // buffer-oriented ceiling would defeat its purpose (and cap legitimate + // multi-gigabyte reads). Output is still bounded per chunk to the declared + // uncompressed size; a caller that wants an explicit cap passes + // options.maxSize. + return decodeMemberStream(this.#rawSource(), { + name: this.name, + flags: this.flags, + method: this.method, + crc32: this.crc32, + uncompressedSize: this.size, + }, options); + } + + // Synchronous serialization: emit the local file header (sec. 4.3.7) + // followed by the raw (already-compressed) content bytes. Streaming + // (source-backed) entries cannot be serialized this way. + *[SymbolIterator]() { + if (this.#source) { + throw new ERR_INVALID_STATE('a streaming entry cannot be serialized synchronously'); + } + const meta = this.#finalizeMeta(); + yield buildLocalHeader(meta); + if (this.#fd !== null) { + yield* this.#rawChunksSync(); + } else if (this.#content?.length) { + yield this.#content; + } + } + + // Asynchronous serialization. For a write-streaming entry, drain the + // source once, computing CRC-32 and sizes on the fly and emitting a Zip64 + // data descriptor after the content (sec. 4.3.9); otherwise defer to the + // buffered/file-backed paths. + async *[SymbolAsyncIterator]() { + const source = this.#source; + if (!source) { + if (this.#fd !== null) { + yield buildLocalHeader(this.#finalizeMeta()); + yield* this.#rawChunks(); + return; + } + yield* this[SymbolIterator](); + return; + } + if (this.#serialized) { + throw new ERR_INVALID_STATE('a streaming entry can only be serialized once'); + } + this.#serialized = true; + const meta = this.#meta; + yield buildLocalHeader(meta); + let state = 0; + let uncompressedSize = 0; + let compressedSize = 0; + const counted = (async function* () { + for await (const chunk of source) { + if (!isUint8Array(chunk)) { + throw new ERR_INVALID_ARG_TYPE('chunk', 'Uint8Array', chunk); + } + if (!chunk.length) continue; + state = crc32Native(chunk, state); + uncompressedSize += chunk.length; + yield chunk; + } + })(); + const output = meta.method === METHOD_DEFLATE ? deflateRawStream(counted) : + meta.method === METHOD_ZSTD ? zstdCompressStream(counted) : counted; + for await (const chunk of output) { + compressedSize += chunk.length; + yield chunk; + } + meta.crc = state; + meta.uncompressedSize = uncompressedSize; + meta.compressedSize = compressedSize; + meta.pending = false; + yield buildDataDescriptor64(meta.crc, compressedSize, uncompressedSize); + } + + // Release the entry's write-source, if it has one. Only a streaming entry + // (`createStream()`) owns a source - a caller-supplied `AsyncIterable`, + // often a file read stream holding a descriptor - and once that entry is + // handed to `createZipArchive()` the caller can no longer reach it, so the + // archive machinery disposes it when the archive finishes or its output + // stream is destroyed early (see `generateZipArchive()`); a caller holding + // an unused streaming entry can dispose it directly. In-memory and + // file-backed entries hold no source - a file-backed entry's descriptor + // belongs to the `ZipFile`, never to the entry - so disposing them is a + // no-op. Disposal is idempotent and marks the entry spent, so a disposed + // streaming entry can no longer be serialized. + #releaseSource() { + const source = this.#source; + this.#source = null; + this.#serialized = true; + return source; + } + [SymbolDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') source.return(); + } + async [SymbolAsyncDispose]() { + const source = this.#releaseSource(); + if (source === null) return; + if (typeof source[SymbolAsyncDispose] === 'function') await source[SymbolAsyncDispose](); + else if (typeof source.destroy === 'function') source.destroy(); + else if (typeof source.return === 'function') await source.return(); + } + + /** + * Builds this entry's central-directory header (sec. 4.3.12) recording its + * local-header start; called by the archive writer once the offset is fixed. + * + * `preserveDescriptorFlag` is set by `ZipFile`'s in-place + * central-directory rewrite, which never regenerates local headers: when + * the on-disk local header advertises a data descriptor (bit 3, which + * `#finalizeMeta()` clears for full re-serialization), the rebuilt central + * header must keep advertising it too, or the two headers would contradict + * each other (sec. 4.3.12 expects them to agree). Full re-serialization + * emits a fresh, bit-3-free local header from the same meta, so there the + * cleared flag is the consistent one. + * @private + * @param {number} localOffset + * @param {boolean} [preserveDescriptorFlag] + * @returns {Buffer} + */ + [kFinalize](localOffset, preserveDescriptorFlag = false) { + validateInteger(localOffset, 'localOffset', 0, NumberMAX_SAFE_INTEGER); + const meta = this.#finalizeMeta(); + if (preserveDescriptorFlag && this.#central !== null && + (this.#central.flags & FLAG_DATA_DESCRIPTOR) !== 0) { + return buildCentralHeader( + { ...meta, flags: meta.flags | FLAG_DATA_DESCRIPTOR }, localOffset); + } + return buildCentralHeader(meta, localOffset); + } + + // Rebind a just-serialized write-streaming entry to its on-disk copy so it + // stops being dead weight: after `addEntry()`/`addEntrySync()` writes the + // entry into `fd` at `localOffset` (its local-header start), the spent + // source is dropped and the entry becomes a readable, re-serializable + // file-backed entry (valid while `fd` stays open). Only a spent stream + // entry - one with neither an in-memory buffer nor an existing backing fd - + // is promoted; in-memory and already-file-backed entries are left as they + // are. The kept `#meta` still supplies the (now-final) name/sizes/crc. + [kPromote](fd, localOffset) { + if (this.#content !== null || this.#fd !== null) return; + this.#fd = fd; + this.#localOffset = localOffset; + this.#contentOffset = -1; + this.#source = null; + this.#serialized = false; + // The descriptor flag has served its purpose: the sizes and CRC are + // known now, and the fd-backed serialization path emits plain headers + // and never writes a descriptor. Left set, a re-serialization would + // advertise (bit 3) a data descriptor that never follows - a corrupt + // archive for any reader that honors the flag. + this.#meta.flags &= ~FLAG_DATA_DESCRIPTOR; + } + + /** + * Parses an in-memory archive, walking the central directory (sec. 4.3.12) + * and each referenced local header (sec. 4.3.7), and yields one read-only + * `ZipEntry` per member. + * @param {Buffer | TypedArray | DataView | ArrayBuffer} buffer + * @yields {ZipEntry} + */ + static *read(buffer) { + const buf = toBuffer(buffer, 'buffer'); + yield* readArchiveEntries(buf, findArchiveEnd(buf)); + } + + /** + * Builds a ready-to-serialize entry from in-memory `data`, compressing it + * with the chosen method (falling back to store when that does not shrink + * it) and recording the CRC-32 and sizes. When the entry ends up stored + * (explicitly or via the fallback) it retains `data`'s memory rather than + * copying it, and the CRC-32 is recorded now - mutating `data` afterwards + * would corrupt the entry on write. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {Promise} + */ + // Shared head of create()/createSync(): validate, snapshot the CRC-32 and + // uncompressed size, and pick the compression method (store is forced for + // directories and empty content). The compression pass itself is the only + // thing the async/sync builders do differently. + static #prepareCreate(filename, data, options) { + const meta = createEntryMeta(filename, options); + const content = toBuffer(data, 'data'); + const isDirectory = StringPrototypeEndsWith(filename, '/'); + if (isDirectory && content.length) { + throw new ERR_INVALID_ARG_VALUE('data', data, 'must be empty for a directory entry'); + } + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + const method = + isDirectory || content.length === 0 || options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + return { meta, content, method }; + } + + // Shared tail of create()/createSync(): keep the compressed bytes only + // when the pass actually shrank the content (otherwise store the original, + // which also retains the caller's memory - see create()'s JSDoc), fill in + // the final sizes, and construct the entry. `compressed` is null when + // `method` is store. + static #finishCreate(meta, content, method, compressed) { + let finalContent = content; + if (compressed !== null && compressed.length < content.length) { + finalContent = compressed; + } else if (compressed !== null) { + method = METHOD_STORE; // Compression did not help; fall back to storing + } + meta.method = method; + meta.compressedSize = finalContent.length; + meta.pending = false; + const entry = new ZipEntry(null, null, finalContent); + entry.#meta = meta; + return entry; + } + + static async create(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? await deflateRawAsync(content) : + method === METHOD_ZSTD ? await zstdCompressAsync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * The synchronous counterpart of `create()`. Blocks the event loop and + * further JavaScript execution until done (including the deflate pass); + * see `contentSync()`. Retains `data`'s memory when storing, same as + * `create()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ + * comment?: string, + * mode?: number, + * modified?: Date, + * method?: 'deflate' | 'store' | 'zstd', + * }} [options] + * @returns {ZipEntry} + */ + static createSync(filename, data, options) { + const { meta, content, method } = ZipEntry.#prepareCreate(filename, data, options); + const compressed = + method === METHOD_DEFLATE ? deflateRawSync(content) : + method === METHOD_ZSTD ? zstdCompressSync(content) : null; + return ZipEntry.#finishCreate(meta, content, method, compressed); + } + + /** + * Builds a write-streaming entry whose content is drained from `source` + * only at serialization time; its sizes/CRC are unknown until then, so it + * sets the data-descriptor flag (bit 3, sec. 4.3.9) and stays pending. + * @param {string} filename + * @param {AsyncIterable} source + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + static createStream(filename, source, options) { + const meta = createEntryMeta(filename, options); + if (StringPrototypeEndsWith(filename, '/')) { + throw new ERR_INVALID_ARG_VALUE('filename', filename, 'a directory entry cannot be streamed'); + } + meta.flags |= FLAG_DATA_DESCRIPTOR; + meta.method = options?.method === 'store' ? METHOD_STORE : + options?.method === 'zstd' ? METHOD_ZSTD : METHOD_DEFLATE; + meta.pending = true; + const entry = new ZipEntry(null, null, null); + entry.#meta = meta; + entry.#source = source; + return entry; + } + + /** + * Creates a symbolic-link entry: a stored entry whose content is the link + * target and whose Unix mode type bits are `S_IFLNK`. + * @param {string} filename + * @param {string} target The link target path. + * @param {{ comment?: string, mode?: number, modified?: Date }} [options] + * @returns {ZipEntry} + */ + static createSymlink(filename, target, options) { + validateString(target, 'target'); + const meta = createEntryMeta(filename, { + __proto__: null, + comment: options?.comment, + mode: options?.mode, + modified: options?.modified, + symlink: true, + }); + const content = Buffer.from(target, 'utf8'); + meta.crc = crc32Native(content, 0); + meta.uncompressedSize = content.length; + meta.method = METHOD_STORE; + meta.compressedSize = content.length; + meta.pending = false; + const entry = new ZipEntry(null, null, content); + entry.#meta = meta; + return entry; + } +} + +// Walk the central directory described by `end` (a findArchiveEnd() result +// for `buf`), yielding one read-only ZipEntry per member. Split from +// `ZipEntry.read()` so `ZipBuffer` - which also needs the archive-end record +// for its comment - can locate the archive end once and share the result +// instead of scanning for it twice. All records are parsed and their member +// ranges cross-checked before the first entry is yielded: members must be +// disjoint and precede the central directory, or one small file could quote +// the same data region from N records - the "quoted overlap" zip-bomb shape +// (CVE-2024-0450 in other implementations). Here the local headers have +// been read, so the check uses each member's exact end (`ZipFile` applies +// the same rule with a lower bound; see `validateMemberBounds()`). +function* readArchiveEntries(buf, end) { + let pos = end.centralDirectoryOffset; + const cdEnd = end.centralDirectoryOffset + end.centralDirectorySize; + const parsed = []; + for (let index = 0; index < end.totalRecords; index++) { + const central = new CentralFileHeader(buf, pos); + if (pos + central.byteLength > cdEnd) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is out of bounds'); + } + if (central.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + const localOffset = central.localFileHeaderOffset + end.prefix; + const local = new LocalFileHeader(buf, localOffset); + const dataStart = localOffset + local.byteLength; + const length = central.compressedSize; + validateArchiveRange(buf, dataStart, length, 'entry data'); + const content = length ? buf.subarray(dataStart, dataStart + length) : EMPTY_BUFFER; + ArrayPrototypePush(parsed, { + entry: new ZipEntry(central, local, content), + start: localOffset, + dataEnd: dataStart + length, + }); + pos = central.byteOffset + central.byteLength; + } + // Sort a separate range list; entries themselves are yielded in central + // directory order. + const ranges = ArrayPrototypeSort(ArrayPrototypeSlice(parsed), (a, b) => a.start - b.start); + for (let i = 0; i < ranges.length; i++) { + const bound = i + 1 < ranges.length ? ranges[i + 1].start : end.centralDirectoryOffset; + if (ranges[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(ranges[i].entry.name)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } + for (let i = 0; i < parsed.length; i++) yield parsed[i].entry; +} + +module.exports = { + createEntryMeta, + readArchiveEntries, + ZipEntry, +}; diff --git a/lib/internal/zip/extra-fields.js b/lib/internal/zip/extra-fields.js new file mode 100644 index 000000000000..0816268411f1 --- /dev/null +++ b/lib/internal/zip/extra-fields.js @@ -0,0 +1,199 @@ +'use strict'; + +// TLV extra-field parsing and building (sec. 4.5): the generic record walker, +// the Zip64 extended-information field, modification-time extras +// (NTFS/UT/UX), the round-trip-preserving strip of Zip64 records, and the +// extended-timestamp builder used on the write path. + +const { + ArrayPrototypePush, + Date, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + ZIP64_EXTRA_ID, + EXTRA_ID_NTFS, + EXTRA_ID_EXT_TIMESTAMP, + EXTRA_ID_UNIX_OLD, +} = require('internal/zip/constants'); +const { readSafeUint64 } = require('internal/zip/binary'); + +// Zip64 extended information extra field (sec. 4.5.3). Walks the TLV records +// itself, and unlike forEachExtraField() below it throws on a malformed +// record: it only runs when a classic header field holds an overflow +// sentinel, so the Zip64 record is required and a malformed extra field +// hides required data. +function parseZip64Extra(extra, want) { + const wanted = + want.uncompressedSize || + want.compressedSize || + want.localFileHeaderOffset || + want.diskNumber; + if (!wanted) return {}; + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('extra field is malformed'); + } + if (id === ZIP64_EXTRA_ID) { + const result = {}; + const body = pos + 4; + const end = body + size; + // APPNOTE 4.5.3: the field order is fixed - uncompressed size (8), + // compressed size (8), local header offset (8), disk number (4) - and + // each field MUST appear only when the corresponding classic field + // holds its overflow sentinel. When the record length matches exactly + // the fields the sentinels call for, parse them packed per spec. + // Real-world writers violate the "only" rule and emit fields for + // non-sentinel values too (commonly all four); such a record is longer + // than the wanted set, and is instead parsed positionally against the + // full layout - the fixed order makes both readings unambiguous. + const wantedSize = + (want.uncompressedSize ? 8 : 0) + + (want.compressedSize ? 8 : 0) + + (want.localFileHeaderOffset ? 8 : 0) + + (want.diskNumber ? 4 : 0); + const packed = size === wantedSize; + let cursor = body; + const take = (fullLayoutOffset, bytes) => { + const at = packed ? cursor : body + fullLayoutOffset; + if (at + bytes > end) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'the Zip64 extended information extra field is truncated'); + } + cursor += bytes; + return bytes === 8 ? readSafeUint64(extra, at) : extra.readUInt32LE(at); + }; + if (want.uncompressedSize) result.uncompressedSize = take(0, 8); + if (want.compressedSize) result.compressedSize = take(8, 8); + if (want.localFileHeaderOffset) result.localFileHeaderOffset = take(16, 8); + if (want.diskNumber) result.diskNumber = take(24, 4); + return result; + } + pos += 4 + size; + } + throw new ERR_ZIP_INVALID_ARCHIVE( + 'a field is 0xFFFFFFFF but the Zip64 extended information extra field is missing'); +} + +// Walk TLV extra-field records - id(2), size(2), body(size) - invoking `cb` +// per record. Stops silently at the first malformed/overrunning record - +// deliberately laxer than parseZip64Extra() above: this walker only feeds +// advisory metadata (timestamps, Unicode names), and a malformed extra +// should not make the archive unreadable. +function forEachExtraField(extra, cb) { + let pos = 0; + while (pos + 4 <= extra.length) { + const id = extra.readUInt16LE(pos); + const size = extra.readUInt16LE(pos + 2); + if (pos + 4 + size > extra.length) break; + cb(id, extra.subarray(pos + 4, pos + 4 + size)); + pos += 4 + size; + } +} + +// The mtime from an NTFS extra field (id 0x000a, sec. 4.5.5): a reserved +// dword then tagged sub-records; tag 1 carries the FILETIME mtime/atime/ctime. +function parseNtfsMtime(body) { + let result = null; + let pos = 4; // Skip the reserved dword. + while (pos + 4 <= body.length) { + const tag = body.readUInt16LE(pos); + const size = body.readUInt16LE(pos + 2); + if (pos + 4 + size > body.length) break; + if (tag === 1 && size >= 8) { + // Windows FILETIME: 100 ns ticks since 1601-01-01 UTC. + const ticks = body.readBigUInt64LE(pos + 4); + result = new Date(Number(ticks / 10000n) - 11644473600000); + } + pos += 4 + size; + } + return result; +} + +// The mtime from an Info-ZIP extended timestamp extra field ("UT", 0x5455; +// listed in APPNOTE's third-party ID table sec. 4.6.1, format defined in +// Info-ZIP's extrafld.txt). +function parseExtTimestampMtime(body) { + // flags(1) then present times; bit 0 => mtime present (signed Unix seconds). + if (body.length < 5 || (body[0] & 1) === 0) return null; + return new Date(body.readInt32LE(1) * 1000); +} + +// The mtime from an Info-ZIP original Unix extra field ("UX", id 0x5855). +function parseUnixOldMtime(body) { + // atime(4), mtime(4) as signed Unix seconds (uid/gid follow only locally). + if (body.length < 8) return null; + return new Date(body.readInt32LE(4) * 1000); +} + +// The highest-fidelity modification time carried in the given extra fields, or +// null when none is present. NTFS (100 ns) beats the extended timestamp (1 s, +// UTC) beats Info-ZIP Unix (1 s); all are absolute instants, unlike the coarse +// local-time DOS date/time fields. +function extraFieldMtime(...extras) { + let ntfs = null; + let ext = null; + let unix = null; + for (const extra of extras) { + if (!extra?.length) continue; + forEachExtraField(extra, (id, body) => { + if (id === EXTRA_ID_NTFS) ntfs ??= parseNtfsMtime(body); + else if (id === EXTRA_ID_EXT_TIMESTAMP) ext ??= parseExtTimestampMtime(body); + else if (id === EXTRA_ID_UNIX_OLD) unix ??= parseUnixOldMtime(body); + }); + } + return ntfs ?? ext ?? unix; +} + +// A copy of `extra` without any Zip64 record (id 0x0001): Zip64 data is +// regenerated from the final sizes/offsets on serialization, but every other +// record (Unicode Path, NTFS/UT timestamps, ...) must survive a round trip - +// dropping them silently renames entries whose display name lives in the +// Unicode Path extra and discards high-fidelity timestamps. +function stripZip64Extra(extra) { + if (!extra.length) return EMPTY_BUFFER; + const parts = []; + let total = 0; + forEachExtraField(extra, (id, body) => { + if (id === ZIP64_EXTRA_ID) return; + const record = Buffer.allocUnsafe(4 + body.length); + record.writeUInt16LE(id, 0); + record.writeUInt16LE(body.length, 2); + body.copy(record, 4); + ArrayPrototypePush(parts, record); + total += record.length; + }); + if (parts.length === 0) return EMPTY_BUFFER; + return Buffer.concat(parts, total); +} + +// Info-ZIP extended timestamp extra field ("UT", 0x5455; see +// parseExtTimestampMtime() above for the format's provenance): a flags byte +// (bit 0 = modification time present) followed by the whole (UTC) second. +function buildExtTimestampExtra(seconds) { + const buffer = Buffer.allocUnsafe(9); + buffer.writeUInt16LE(EXTRA_ID_EXT_TIMESTAMP, 0); + buffer.writeUInt16LE(5, 2); + buffer.writeUInt8(0x01, 4); + buffer.writeInt32LE(seconds, 5); + return buffer; +} + +module.exports = { + parseZip64Extra, + forEachExtraField, + extraFieldMtime, + stripZip64Extra, + buildExtTimestampExtra, +}; diff --git a/lib/internal/zip/file.js b/lib/internal/zip/file.js new file mode 100644 index 000000000000..b43c091e8630 --- /dev/null +++ b/lib/internal/zip/file.js @@ -0,0 +1,780 @@ +'use strict'; + +// `ZipFile`: random-access, in-place-writable view over an archive on disk +// (a raw fd), plus `buildCentralDirectoryChunks()` which rebuilds the central +// directory (and its Zip64/EOCD trailer) for the in-place rewrite path. + +const { + ArrayPrototypePush, + ArrayPrototypeSort, + FunctionPrototypeCall, + JSONStringify, + Map, + MapPrototypeClear, + MapPrototypeDelete, + MapPrototypeEntries, + MapPrototypeGet, + MapPrototypeGetSize, + MapPrototypeHas, + MapPrototypeKeys, + MapPrototypeSet, + MathMin, + PromisePrototypeThen, + PromiseResolve, + SymbolAsyncDispose, + SymbolAsyncIterator, + SymbolDispose, + SymbolIterator, + SymbolToStringTag, +} = primordials; + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_ZIP_ARCHIVE_TOO_LARGE, + ERR_ZIP_ENTRY_NOT_FOUND, + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_NOT_WRITABLE, + }, +} = require('internal/errors'); +const { + validateBoolean, + validateFunction, + validateString, +} = require('internal/validators'); +const { Buffer, kMaxLength } = require('buffer'); +const { Readable } = require('stream'); +const fs = require('fs'); +const { + SIG_LOCAL_FILE_HEADER, + TAIL_LENGTH, + kFinalize, + kPromote, +} = require('internal/zip/constants'); +const { + buildArchiveTrailer, +} = require('internal/zip/header-builders'); +const { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +} = require('internal/zip/headers'); +const { decodeZipText } = require('internal/zip/dos'); +const { + fsOpenAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +} = require('internal/zip/fs-util'); +const { ZipEntry } = require('internal/zip/entry'); +const { + createZipArchive, + createZipArchiveSync, +} = require('internal/zip/archive'); + +/** + * Builds a fresh central directory (sec. 4.3.12) plus its trailer (the Zip64 + * end record/locator when needed and the end-of-central-directory record; + * see `buildArchiveTrailer()`) for `records`, an array of + * `{ entry, localOffset }` pairs already in their final order and at their + * final (possibly pre-existing, possibly freshly written) offsets. + * @param {Array<{ entry: ZipEntry, localOffset: number }>} records + * @param {number} centralDirectoryOffset + * @param {Buffer} comment + * @returns {{ centralHeaders: Buffer[], chunks: Buffer[] }} + */ +function buildCentralDirectoryChunks(records, centralDirectoryOffset, comment) { + const centralHeaders = []; + let centralDirectorySize = 0; + for (let i = 0; i < records.length; i++) { + // `true`: this rewrite leaves local headers on disk untouched, so an + // entry whose local header advertises a data descriptor (bit 3) must + // keep advertising it in the rebuilt central header; see [kFinalize]. + const header = records[i].entry[kFinalize](records[i].localOffset, true); + ArrayPrototypePush(centralHeaders, header); + centralDirectorySize += header.length; + } + const count = records.length; + const chunks = []; + for (let i = 0; i < centralHeaders.length; i++) ArrayPrototypePush(chunks, centralHeaders[i]); + const trailer = buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment); + for (let i = 0; i < trailer.length; i++) ArrayPrototypePush(chunks, trailer[i]); + return { centralHeaders, chunks }; +} + +// Shared post-location validation for open()/openSync(): the archive tail +// has been found; make sure the central directory it describes can actually +// be buffered and lies inside the file. +function checkArchiveEnd(end, size) { + if (end.centralDirectorySize > kMaxLength) { + throw new ERR_ZIP_ARCHIVE_TOO_LARGE('the central directory is too large to buffer'); + } + if (end.centralDirectoryOffset + end.centralDirectorySize > size) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory is out of bounds'); + } +} + +// Given the fixed 30-byte local header read at `offset`, return where the +// member's data ends. The data starts after the full local header (fixed + +// file name + extra field), whose length lives only in the local header, not +// the central directory - so the exact end can only be measured by reading it. +// A malformed local header (bad signature) is not rejected here: fall back to +// the 30-byte minimum so the archive stays openable (a read of that entry will +// surface the error) while the member is still bounded by a safe lower bound. +// Well-formed headers - the case an overlap attack must use to be readable - +// are measured exactly, matching the read path and the in-memory reader. +function localHeaderEnd(fixed, offset, compressedSize) { + if (fixed.readUInt32LE(0) !== SIG_LOCAL_FILE_HEADER) { + return offset + 30 + compressedSize; + } + return offset + LocalFileHeader.length(fixed, 0) + compressedSize; +} + +// Reject any member whose actual compressed bytes cannot lie inside the file, +// and any pair of members whose data ranges overlap each other or the central +// directory. The buffered read paths (`ZipEntry.prototype.content()` and +// friends) allocate `compressedSize` bytes before reading, so without the +// bounds check a tiny file whose central directory lies about a member's size +// could force an allocation of up to `kMaxLength` bytes. The overlap check +// counters the "quoted overlap" zip-bomb shape (CVE-2024-0450 in other +// implementations): N records quoting the same data region turn one small file +// into N full-size extractions, while real archives lay members out +// disjointly. The exact data range depends on the local header's length, which +// is read here (one small read per member) so the check matches the read path +// and the in-memory reader (`readArchiveEntries()`) rather than trusting a +// looser lower bound. +async function validateMemberBounds(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + await readFdFully(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Sync counterpart of validateMemberBounds(). +function validateMemberBoundsSync(fd, headers, prefix, size, centralDirectoryOffset) { + const members = []; + for (let i = 0; i < headers.length; i++) { + const header = headers[i]; + const offset = header.localFileHeaderOffset + prefix; + const fixed = Buffer.allocUnsafe(30); + readFdFullySync(fd, fixed, offset); + const dataEnd = localHeaderEnd(fixed, offset, header.compressedSize); + checkMemberRange(members, header.fileName, offset, dataEnd, size); + } + checkMemberOverlap(members, centralDirectoryOffset); +} + +// Record a member's [offset, dataEnd) range, rejecting one that runs past the +// end of the file. +function checkMemberRange(members, fileName, offset, dataEnd, size) { + if (dataEnd > size) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(fileName)} data is out of bounds`); + } + ArrayPrototypePush(members, { fileName, offset, dataEnd }); +} + +// Sorted by local-header start, reject any member whose data runs into the +// next member's local header or into the central directory. +function checkMemberOverlap(members, centralDirectoryOffset) { + ArrayPrototypeSort(members, (a, b) => a.offset - b.offset); + for (let i = 0; i < members.length; i++) { + const bound = i + 1 < members.length ? members[i + 1].offset : centralDirectoryOffset; + if (members[i].dataEnd > bound) { + throw new ERR_ZIP_INVALID_ARCHIVE( + `entry ${JSONStringify(members[i].fileName)} overlaps the next ` + + 'entry or the central directory (possible zip bomb)'); + } + } +} + +/** + * A random-access view over the entries of a ZIP archive on disk. Only the + * archive tail and central directory are read up front; individual member + * content is read lazily and on demand. Writable when opened with + * `{ writable: true }`: adding or deleting an entry rewrites the central + * directory in place, appending new entry content where the old central + * directory used to be. + * + * Every method has a `*Sync` counterpart. The synchronous methods block the + * Node.js event loop and further JavaScript execution until the operation + * completes - use them only where synchronous I/O is appropriate (for + * example, short-lived scripts or startup code), never in code that must + * stay responsive. A synchronous method throws `ERR_INVALID_STATE` if called + * while an asynchronous `addEntry()`/`add()`/`delete()`/`close()` on the same + * `ZipFile` has not settled yet, since letting the two interleave could + * corrupt the archive. + */ +class ZipFile { + // Shared descriptor handle ({ fd, closed }) handed to every ZipEntry this + // archive produces, so close() can invalidate them all at once. `#closing` + // is set synchronously the moment close()/closeSync() is called, gating any + // further public call; `handle.closed` is set once the fd is actually gone, + // gating reads through already-handed-out entries. Neither read ever falls + // through to a bare (possibly OS-reused) descriptor number. + #handle; + #closing = false; + #closePromise = null; + #writable; + #comment; + #centralDirectoryOffset; + #entries = new Map(); + #queue = PromiseResolve(); + #pendingAsyncOps = 0; + + /** + * Builds the by-name entry map from the already-parsed central headers, + * recording each member's local-header offset (adjusted by any prefix). + * @private + */ + constructor(fd, centralHeaders, prefix, centralDirectoryOffset, comment, writable) { + this.#handle = { fd, closed: false }; + this.#writable = writable; + this.#comment = comment; + this.#centralDirectoryOffset = centralDirectoryOffset; + for (let i = 0; i < centralHeaders.length; i++) { + const central = centralHeaders[i]; + MapPrototypeSet(this.#entries, central.fileName, { + central, + entry: undefined, + localOffset: central.localFileHeaderOffset + prefix, + }); + } + } + get writable() { return this.#writable; } + // The EOCD comment has no encoding flag; apply the same UTF-8/CP437 + // heuristic as unflagged member names and comments. + get comment() { return decodeZipText(this.#comment, 0); } + // Guard: throw unless this archive was opened writable. + #assertWritable() { + if (!this.#writable) throw new ERR_ZIP_NOT_WRITABLE(); + } + // Guard: reject any operation once close() has been initiated, so a + // request never reaches a closed (or reused) descriptor. Set synchronously + // by close()/closeSync() so an add() issued after them cannot slip through. + #assertOpen() { + if (this.#closing) { + throw new ERR_INVALID_STATE('the ZipFile has been closed'); + } + } + // Guard: reject a synchronous call while an async mutation is still in + // flight, since interleaving the two could corrupt the archive. + #assertNotBusy() { + if (this.#pendingAsyncOps > 0) { + throw new ERR_INVALID_STATE( + 'cannot call a synchronous ZipFile method while an asynchronous ' + + 'add(), addEntry(), delete(), or close() call has not settled yet'); + } + } + // Serialize async mutations (add/delete/close) through a promise chain so + // they never overlap and corrupt the archive; the in-flight counter backs + // #assertNotBusy(). Failures do not break the chain (both arms continue it). + #enqueue(fn) { + this.#pendingAsyncOps++; + const run = async () => { + try { + return await fn(); + } finally { + this.#pendingAsyncOps--; + } + }; + const result = PromisePrototypeThen(this.#queue, run, run); + this.#queue = PromisePrototypeThen(result, () => undefined, () => undefined); + return result; + } + has(name) { + this.#assertOpen(); + validateString(name, 'name'); + return MapPrototypeHas(this.#entries, name); + } + // Return the lazy, file-backed ZipEntry handle for `info`, creating and + // caching it on first access. The handle stores only a descriptor and the + // local-header offset - never the member's content - so repeated `get()`s + // return the same lightweight object and no content buffer is retained by + // the ZipFile. Any read (`content()`, `contentIterator()`) goes to disk. + #handleFor(info) { + info.entry ??= new ZipEntry(info.central, null, null, this.#handle, info.localOffset); + return info.entry; + } + /** + * Returns a lazy, file-backed `ZipEntry` for `name`. Nothing is read from + * disk here and no content is buffered; the entry reads (and, for + * `content()`, decompresses) straight from the file on each access. The + * returned entry is valid only while this `ZipFile` is open. + * @param {string} name + * @returns {Promise} + */ + async get(name) { + this.#assertOpen(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * The synchronous counterpart of `get()`. Like `get()`, it reads nothing + * up front and buffers no content - it only builds the lazy handle - so it + * does not itself block on I/O; see the class-level note on synchronous + * methods for reads performed later through the returned entry. + * @param {string} name + * @returns {ZipEntry} + */ + getSync(name) { + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const info = MapPrototypeGet(this.#entries, name); + if (info === undefined) throw new ERR_ZIP_ENTRY_NOT_FOUND(name); + return this.#handleFor(info); + } + /** + * Streams a member's decoded content without buffering the whole member, + * as a `Readable` (verifying CRC-32 by default; `{ verify: false }` to opt + * out). Sugar for wrapping `get(name).contentIterator(options)`; the + * compressed bytes are read from disk as the stream is consumed. + * @param {string} name + * @param {{ verify?: boolean, maxSize?: number }} [options] + * @returns {Promise} + */ + async stream(name, options) { + const entry = await this.get(name); + return Readable.from(entry.contentIterator(options), { objectMode: false }); + } + /** + * Writes `entry`'s serialized bytes where the central directory currently + * starts, then rewrites the central directory to include it. Replaces any + * existing entry of the same name (its bytes become dead space, reclaimed + * by `compact()`). + * @param {ZipEntry} entry + * @returns {Promise} + */ + async addEntry(entry) { + this.#assertWritable(); + this.#assertOpen(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + return this.#enqueue(() => this.#doAdd(entry)); + } + /** + * Builds an entry from in-memory `data` and appends it; see `addEntry()`. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {Promise} + */ + async add(filename, data, options) { + this.#assertWritable(); + this.#assertOpen(); + // Reserve the mutation synchronously (before the async ZipEntry.create()), + // so a close()/closeSync() issued right after cannot slip in front of it + // and tear down the descriptor this write depends on. + return this.#enqueue(async () => + this.#doAdd(await ZipEntry.create(filename, data, options))); + } + // Append the entry's bytes where the central directory currently starts, + // then rewrite the directory to include it. On write failure, restore the + // original directory (the partial write may have clobbered it) and rethrow; + // on success, promote a spent stream entry to its on-disk copy. + async #doAdd(entry) { + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for await (const chunk of entry) { + await writeFdFully(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // The failed entry's bytes start where the old central directory + // started, so part of it may already be overwritten - while the EOCD + // still points at it. Nothing has been adopted into memory yet, so + // rebuild and rewrite the directory (and EOCD) at its original offset + // to leave the archive exactly as it was before the call. + try { + await this.#rewriteCentralDirectory(); + } catch { + // Restoring failed too (the device is likely full or gone); the + // original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + await this.#rewriteCentralDirectory(); + // The entry now has a stable home in this archive; if it was a spent + // streaming entry, rebind it to that on-disk copy so it stays readable. + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `addEntry()`. `entry` must not be a + * pending streaming entry (one created with `ZipEntry.createStream()`) - + * there is no synchronous way to drain its asynchronous source. Blocks the + * event loop until done; see the class-level note on synchronous methods. + * @param {ZipEntry} entry + * @returns {ZipEntry} + */ + addEntrySync(entry) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + if (!(entry instanceof ZipEntry)) { + throw new ERR_INVALID_ARG_TYPE('entry', 'ZipEntry', entry); + } + const localOffset = this.#centralDirectoryOffset; + let written = 0; + try { + for (const chunk of entry) { + writeFdFullySync(this.#handle.fd, chunk, localOffset + written); + written += chunk.length; + } + } catch (err) { + // See #doAdd(): restore the (partially overwritten) central directory + // before surfacing the failure. + try { + this.#rewriteCentralDirectorySync(); + } catch { + // Restoring failed too; the original error is the actionable one. + } + throw err; + } + this.#centralDirectoryOffset = localOffset + written; + MapPrototypeSet(this.#entries, entry.name, { central: null, entry, localOffset }); + this.#rewriteCentralDirectorySync(); + entry[kPromote](this.#handle, localOffset); + return entry; + } + /** + * The synchronous counterpart of `add()`. Blocks the event loop until + * done (including the deflate pass); see the class-level note on + * synchronous methods. + * @param {string} filename + * @param {Buffer | TypedArray | DataView | ArrayBuffer} data + * @param {{ comment?: string, mode?: number, modified?: Date, method?: 'deflate' | 'store' | 'zstd' }} [options] + * @returns {ZipEntry} + */ + addSync(filename, data, options) { + this.#assertWritable(); + return this.addEntrySync(ZipEntry.createSync(filename, data, options)); + } + /** + * Removes an entry by name. The central directory is rewritten in place + * (no new content is written, so the archive does not grow); the removed + * entry's bytes become dead space, reclaimed by `compact()`. + * @param {string} name + * @returns {Promise} + */ + async delete(name) { + this.#assertWritable(); + this.#assertOpen(); + validateString(name, 'name'); + return this.#enqueue(() => this.#doDelete(name)); + } + // Drop the named entry and rewrite the central directory (no member bytes + // move, so the file does not grow); reports whether it existed. + async #doDelete(name) { + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) await this.#rewriteCentralDirectory(); + return existed; + } + /** + * The synchronous counterpart of `delete()`. Blocks the event loop until + * done; see the class-level note on synchronous methods. + * @param {string} name + * @returns {boolean} + */ + deleteSync(name) { + this.#assertWritable(); + this.#assertOpen(); + this.#assertNotBusy(); + validateString(name, 'name'); + const existed = MapPrototypeDelete(this.#entries, name); + if (existed) this.#rewriteCentralDirectorySync(); + return existed; + } + // Snapshot the live entries as ordered { entry, localOffset } records for a + // central-directory rebuild, materializing a plain ZipEntry for any member + // not yet handed out as a lazy handle. + #liveRecords() { + const records = []; + const names = []; + for (const { 0: name, 1: value } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(records, { + entry: value.entry ?? new ZipEntry(value.central, null, null), + localOffset: value.localOffset, + }); + ArrayPrototypePush(names, name); + } + return { records, names }; + } + // Rebuild the central directory and its trailer (sec. 4.3.12/4.3.16) for the + // current live set and overwrite it in place at its current offset, + // truncating any leftover tail, then adopt the freshly written headers. + async #rewriteCentralDirectory() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + await writeFdFully(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + await fsFtruncateAsync(this.#handle.fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + // Sync counterpart of #rewriteCentralDirectory(). + #rewriteCentralDirectorySync() { + const { records, names } = this.#liveRecords(); + const { centralHeaders, chunks } = buildCentralDirectoryChunks( + records, this.#centralDirectoryOffset, this.#comment); + let pos = this.#centralDirectoryOffset; + for (let i = 0; i < chunks.length; i++) { + writeFdFullySync(this.#handle.fd, chunks[i], pos); + pos += chunks[i].length; + } + fs.ftruncateSync(this.#handle.fd, pos); + this.#adoptRewrittenCentralDirectory(names, centralHeaders, records); + } + // Re-derives fresh, disk-backed central headers from what was just + // written, so every entry - original or freshly added - is uniformly + // readable by offset from now on, regardless of whether its in-memory + // ZipEntry (e.g. a streaming entry, whose source can only be consumed + // once) is still around. + #adoptRewrittenCentralDirectory(names, centralHeaders, records) { + for (let i = 0; i < names.length; i++) { + MapPrototypeSet(this.#entries, names[i], { + central: new CentralFileHeader(centralHeaders[i], 0), + entry: undefined, + localOffset: records[i].localOffset, + }); + } + } + /** + * Serializes the currently live entries into a fresh archive stream, + * leaving behind any dead space left by prior `addEntry()`/`delete()` + * calls. Does not modify the open file; pipe the result into a new one. + * @param {string} [comment] + * @returns {import('stream').Readable} + */ + compact(comment) { + this.#assertOpen(); + // Snapshot the live set now: a later addEntry()/delete() must not error + // out (or change) an archive stream that is already being produced. + // Reading the snapshot stays valid regardless of later mutations - + // neither addEntry() nor delete() moves existing member bytes. + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + return createZipArchive(entries, { comment: comment ?? this.#comment }); + } + /** + * The synchronous counterpart of `compact()`. Blocks the event loop until + * the whole archive has been read and re-serialized; see the class-level + * note on synchronous methods. + * @param {string} [comment] + * @returns {Buffer} + */ + compactSync(comment) { + this.#assertOpen(); + this.#assertNotBusy(); + const entries = []; + for (const { 1: info } of MapPrototypeEntries(this.#entries)) { + ArrayPrototypePush(entries, this.#handleFor(info)); + } + const chunks = []; + for (const chunk of createZipArchiveSync(entries, { comment: comment ?? this.#comment })) { + ArrayPrototypePush(chunks, chunk); + } + return Buffer.concat(chunks); + } + keys() { this.#assertOpen(); return MapPrototypeKeys(this.#entries); } + *values() { + for (const name of this.keys()) yield this.get(name); + } + /** + * The synchronous counterpart of `values()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {ZipEntry} + */ + *valuesSync() { + for (const name of this.keys()) yield this.getSync(name); + } + *entries() { + for (const name of this.keys()) yield [name, this.get(name)]; + } + /** + * The synchronous counterpart of `entries()`, yielding resolved `ZipEntry` + * values instead of `Promise`s. + * @yields {[string, ZipEntry]} + */ + *entriesSync() { + for (const name of this.keys()) yield [name, this.getSync(name)]; + } + // Async iteration yields each resolved ZipEntry (awaiting the lazy handles). + async *[SymbolAsyncIterator]() { + for (const promise of this.values()) yield await promise; + } + get size() { this.#assertOpen(); return MapPrototypeGetSize(this.#entries); } + [SymbolIterator]() { return this.entries(); } + get [SymbolToStringTag]() { return 'ZipFile'; } + forEach(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entries()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + /** + * The synchronous counterpart of `forEach()`, invoking `callback` with a + * resolved `ZipEntry` instead of a `Promise`. + * @param {Function} callback + * @param {*} [thisArg] + */ + forEachSync(callback, thisArg) { + validateFunction(callback, 'callback'); + for (const { 0: key, 1: value } of this.entriesSync()) { + FunctionPrototypeCall(callback, thisArg === undefined ? this : thisArg, value, key, this); + } + } + // Drop all entries and close the fd, queued behind any pending async ops. + // Idempotent: a second close() is a safe no-op, never a double-close of a + // (possibly reused) descriptor. `#closing` is set synchronously so any + // operation issued after this point is rejected rather than racing the fd. + close() { + if (this.#closing) return this.#closePromise ?? PromiseResolve(); + this.#closing = true; + this.#closePromise = this.#enqueue(async () => { + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + await fsCloseAsync(this.#handle.fd); + }); + return this.#closePromise; + } + /** + * The synchronous counterpart of `close()`; see the class-level note on + * synchronous methods. Idempotent. + */ + closeSync() { + if (this.#closing) return; + this.#assertNotBusy(); + this.#closing = true; + this.#handle.closed = true; + MapPrototypeClear(this.#entries); + fs.closeSync(this.#handle.fd); + } + async [SymbolAsyncDispose]() { + await this.close(); + } + [SymbolDispose]() { + this.closeSync(); + } + /** + * Opens an archive, reading only its tail and central directory up front + * (member content stays on disk). Pass `{ writable: true }` for in-place + * editing. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {Promise} + */ + static async open(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = await fsOpenAsync(filename, writable ? 'r+' : 'r'); + try { + const stat = await fsFstatAsync(fd); + const size = stat.size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + await readFdFully(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // The Zip64 EOCD record's extensible data sector pushes the record + // start beyond the fixed-size tail; re-read from the recorded + // offset (bounded by ZIP64_EOCD_MAX_LENGTH inside findArchiveEnd). + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + await readFdFully(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + await readFdFully(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + await validateMemberBounds(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + await fsCloseAsync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } + /** + * The synchronous counterpart of `open()`. Blocks the event loop and + * further JavaScript execution until the archive's tail and central + * directory have been read; see the class-level note on synchronous + * methods. + * @param {string} filename + * @param {{ writable?: boolean }} [options] + * @returns {ZipFile} + */ + static openSync(filename, options) { + validateString(filename, 'filename'); + const writable = options?.writable ?? false; + validateBoolean(writable, 'options.writable'); + const fd = fs.openSync(filename, writable ? 'r+' : 'r'); + try { + const size = fs.fstatSync(fd).size; + const tailLength = MathMin(size, TAIL_LENGTH); + const tail = Buffer.allocUnsafe(tailLength); + readFdFullySync(fd, tail, size - tailLength); + let end = findArchiveEnd(tail, size - tailLength); + if (end.needTailFrom !== undefined) { + // See open(): the Zip64 EOCD record starts before the fixed-size + // tail; re-read from the recorded offset. + const retry = Buffer.allocUnsafe(size - end.needTailFrom); + readFdFullySync(fd, retry, end.needTailFrom); + end = findArchiveEnd(retry, end.needTailFrom); + if (end.needTailFrom !== undefined) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory record not found'); + } + } + checkArchiveEnd(end, size); + const directory = Buffer.allocUnsafe(end.centralDirectorySize); + readFdFullySync(fd, directory, end.centralDirectoryOffset); + const headers = readCentralDirectory(directory, end.totalRecords); + validateMemberBoundsSync(fd, headers, end.prefix, size, end.centralDirectoryOffset); + return new ZipFile(fd, headers, end.prefix, end.centralDirectoryOffset, end.comment, writable); + } catch (err) { + try { + fs.closeSync(fd); + } catch { + // The archive failed to parse; the close error is not actionable. + } + throw err; + } + } +} + +module.exports = { + ZipFile, +}; diff --git a/lib/internal/zip/fs-util.js b/lib/internal/zip/fs-util.js new file mode 100644 index 000000000000..cfd20069d007 --- /dev/null +++ b/lib/internal/zip/fs-util.js @@ -0,0 +1,150 @@ +'use strict'; + +// Promise wrappers over the callback `fs` primitives (`ZipFile` works on a +// raw fd so one instance can serve both async and `Sync` methods), plus the +// short-read/short-write loops that guarantee a full transfer. + +const { + Promise, +} = primordials; + +const { + codes: { + ERR_INVALID_STATE, + ERR_ZIP_INVALID_ARCHIVE, + }, +} = require('internal/errors'); +const fs = require('fs'); + +// Promisified adapters over the callback `fs` primitives; individually trivial, +// they only exist so the async archive paths can `await` plain fd operations. +// +// `ZipFile` operates on a plain numeric file descriptor (rather than an +// `fs.promises` `FileHandle`) so that a single instance can support both the +// async and the `Sync` methods: `fs.read`/`fs.write`/`fs.fstat`/ +// `fs.ftruncate`/`fs.close` all accept a raw fd directly, same as their +// `*Sync` counterparts, so both call sites share one open file underneath. +function fsOpenAsync(path, flag) { + return new Promise((resolve, reject) => { + fs.open(path, flag, (err, fd) => (err ? reject(err) : resolve(fd))); + }); +} + +function fsStatAsync(path) { + return new Promise((resolve, reject) => { + fs.stat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsLstatAsync(path) { + return new Promise((resolve, reject) => { + fs.lstat(path, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadlinkAsync(path) { + return new Promise((resolve, reject) => { + fs.readlink(path, 'utf8', (err, target) => (err ? reject(err) : resolve(target))); + }); +} + +function fsCloseAsync(fd) { + return new Promise((resolve, reject) => { + fs.close(fd, (err) => (err ? reject(err) : resolve())); + }); +} + +function fsFstatAsync(fd) { + return new Promise((resolve, reject) => { + fs.fstat(fd, (err, stats) => (err ? reject(err) : resolve(stats))); + }); +} + +function fsReadAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.read(fd, buffer, offset, length, position, (err, bytesRead) => (err ? reject(err) : resolve(bytesRead))); + }); +} + +function fsWriteAsync(fd, buffer, offset, length, position) { + return new Promise((resolve, reject) => { + fs.write(fd, buffer, offset, length, position, (err, bytesWritten) => (err ? reject(err) : resolve(bytesWritten))); + }); +} + +function fsFtruncateAsync(fd, len) { + return new Promise((resolve, reject) => { + fs.ftruncate(fd, len, (err) => (err ? reject(err) : resolve())); + }); +} + +// `read(2)` may return fewer bytes than requested without hitting EOF, so loop +// until `buffer` is entirely filled; a genuine short read (0 bytes) means the +// archive is truncated where a full header/record was expected. +async function readFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = await fsReadAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// Synchronous counterpart of `readFdFully()`; same short-read loop. +function readFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesRead = fs.readSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesRead <= 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('unexpected end of file'); + } + done += bytesRead; + } +} + +// `write(2)` may write fewer bytes than asked - most notably when an error +// (ENOSPC, EIO, a full NFS commit) strikes after partial progress, which +// surfaces as a short, error-free count. Advancing archive offsets by the +// intended length would then silently corrupt the file, so every archive +// write loops until the buffer is fully on disk; retrying the remainder +// re-encounters and surfaces the underlying error. +async function writeFdFully(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + await fsWriteAsync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +// Synchronous counterpart of `writeFdFully()`; same short-write loop. +function writeFdFullySync(fd, buffer, position) { + let done = 0; + while (done < buffer.length) { + const bytesWritten = + fs.writeSync(fd, buffer, done, buffer.length - done, position + done); + if (bytesWritten <= 0) { + throw new ERR_INVALID_STATE('a write to the archive made no progress'); + } + done += bytesWritten; + } +} + +module.exports = { + fsOpenAsync, + fsStatAsync, + fsLstatAsync, + fsReadlinkAsync, + fsCloseAsync, + fsFstatAsync, + fsFtruncateAsync, + readFdFully, + readFdFullySync, + writeFdFully, + writeFdFullySync, +}; diff --git a/lib/internal/zip/header-builders.js b/lib/internal/zip/header-builders.js new file mode 100644 index 000000000000..4b48fbc53180 --- /dev/null +++ b/lib/internal/zip/header-builders.js @@ -0,0 +1,251 @@ +'use strict'; + +// Write-path header builders: local/central file headers, the Zip64 data +// descriptor, and the (Zip64) end-of-central-directory records, plus the +// version-needed and extra-length helpers they share. + +const { + ArrayPrototypePush, + MathMax, + MathMin, +} = primordials; + +const { + codes: { + ERR_ZIP_ENTRY_TOO_LARGE, + }, +} = require('internal/errors'); +const { Buffer } = require('buffer'); +const { + EMPTY_BUFFER, + SIG_LOCAL_FILE_HEADER, + SIG_DATA_DESCRIPTOR, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + ZIP64_EXTRA_ID, + SENTINEL16, + SENTINEL32, + FLAG_DATA_DESCRIPTOR, + METHOD_ZSTD, + VERSION_DEFAULT, + VERSION_ZIP64, + VERSION_ZSTD, +} = require('internal/zip/constants'); +const { encodeDosDateTime } = require('internal/zip/dos'); +const { writeSafeUint64 } = require('internal/zip/binary'); +const { buildExtTimestampExtra } = require('internal/zip/extra-fields'); + +// The "version needed to extract" (sec. 4.4.3): the highest version any +// feature of the member demands - 6.3 for Zstandard, 4.5 for Zip64 +// structures, 2.0 otherwise. +function versionNeeded(meta, zip64) { + return MathMax( + zip64 ? VERSION_ZIP64 : VERSION_DEFAULT, + meta.method === METHOD_ZSTD ? VERSION_ZSTD : 0); +} + +// Guard the combined extra-field length against the 16-bit field it is stored +// in (sec. 4.4.11). +function checkExtraLength(extraLength) { + if (extraLength > SENTINEL16) { + throw new ERR_ZIP_ENTRY_TOO_LARGE( + 'the entry extra fields must not exceed 65535 bytes'); + } +} + +// Build a local file header (sec. 4.3.7). Streamed entries (unknown sizes/CRC) +// zero those fields and always carry a Zip64 extra so the trailing data +// descriptor can hold 64-bit sizes; oversized non-streamed entries get one too. +function buildLocalHeader(meta) { + const streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const zip64 = + streaming || + meta.compressedSize >= SENTINEL32 || + meta.uncompressedSize >= SENTINEL32; + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64 ? 20 : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const buffer = Buffer.allocUnsafe(30 + meta.name.length + extraLength); + buffer.writeUInt32LE(SIG_LOCAL_FILE_HEADER, 0); + buffer.writeUInt16LE(versionNeeded(meta, zip64), 4); + buffer.writeUInt16LE(meta.flags, 6); + buffer.writeUInt16LE(meta.method, 8); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 10); + buffer.writeUInt16LE(date, 12); + buffer.writeUInt32LE(streaming ? 0 : meta.crc, 14); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.compressedSize, 18); + buffer.writeUInt32LE(zip64 ? SENTINEL32 : meta.uncompressedSize, 22); + buffer.writeUInt16LE(meta.name.length, 26); + buffer.writeUInt16LE(extraLength, 28); + meta.name.copy(buffer, 30); + let pos = 30 + meta.name.length; + if (zip64) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(16, pos + 2); + writeSafeUint64(buffer, pos + 4, streaming ? 0 : meta.uncompressedSize); + writeSafeUint64(buffer, pos + 12, streaming ? 0 : meta.compressedSize); + pos += 20; + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + return buffer; +} + +// Build a central directory file header (sec. 4.3.12). Each of the +// uncompressed size, compressed size, and local-header offset that overflows +// 32 bits is moved into the Zip64 extra field (sec. 4.5.3) in that order. +function buildCentralHeader(meta, localOffset) { + const zip64Streaming = (meta.flags & FLAG_DATA_DESCRIPTOR) !== 0; + const u64 = meta.uncompressedSize >= SENTINEL32; + const c64 = meta.compressedSize >= SENTINEL32; + const o64 = localOffset >= SENTINEL32; + const zip64Fields = (u64 ? 1 : 0) + (c64 ? 1 : 0) + (o64 ? 1 : 0); + const ts = meta.extendedMtime !== null ? buildExtTimestampExtra(meta.extendedMtime) : EMPTY_BUFFER; + const extraLength = (zip64Fields ? 4 + 8 * zip64Fields : 0) + ts.length + meta.extra.length; + checkExtraLength(extraLength); + const zip64 = zip64Streaming || zip64Fields > 0; + const version = versionNeeded(meta, zip64); + const buffer = Buffer.allocUnsafe( + 46 + meta.name.length + extraLength + meta.comment.length); + buffer.writeUInt32LE(SIG_CENTRAL_FILE_HEADER, 0); + // "Version made by" (sec. 4.4.2): the upper byte is the creator's host + // system, which determines how the external attributes (sec. 4.4.15) are + // interpreted. Preserve the original host for round-tripped entries - + // stamping everything as Unix would turn, say, a DOS entry's zeroed high + // bits into Unix mode 0000. Entries built by `createEntryMeta()` are Unix. + buffer.writeUInt16LE((meta.madeBy << 8) | version, 4); + buffer.writeUInt16LE(version, 6); + buffer.writeUInt16LE(meta.flags, 8); + buffer.writeUInt16LE(meta.method, 10); + const { time, date } = encodeDosDateTime(meta.modified); + buffer.writeUInt16LE(time, 12); + buffer.writeUInt16LE(date, 14); + buffer.writeUInt32LE(meta.crc, 16); + buffer.writeUInt32LE(c64 ? SENTINEL32 : meta.compressedSize, 20); + buffer.writeUInt32LE(u64 ? SENTINEL32 : meta.uncompressedSize, 24); + buffer.writeUInt16LE(meta.name.length, 28); + buffer.writeUInt16LE(extraLength, 30); + buffer.writeUInt16LE(meta.comment.length, 32); + buffer.writeUInt16LE(0, 34); // disk number + buffer.writeUInt16LE(meta.internal, 36); + buffer.writeUInt32LE(meta.external, 38); + buffer.writeUInt32LE(o64 ? SENTINEL32 : localOffset, 42); + meta.name.copy(buffer, 46); + let pos = 46 + meta.name.length; + if (zip64Fields) { + buffer.writeUInt16LE(ZIP64_EXTRA_ID, pos); + buffer.writeUInt16LE(8 * zip64Fields, pos + 2); + pos += 4; + if (u64) { + writeSafeUint64(buffer, pos, meta.uncompressedSize); + pos += 8; + } + if (c64) { + writeSafeUint64(buffer, pos, meta.compressedSize); + pos += 8; + } + if (o64) { + writeSafeUint64(buffer, pos, localOffset); + pos += 8; + } + } + ts.copy(buffer, pos); + pos += ts.length; + meta.extra.copy(buffer, pos); + pos += meta.extra.length; + meta.comment.copy(buffer, pos); + return buffer; +} + +// Zip64 data descriptor (sec. 4.3.9): emitted after a streamed entry, whose +// local header always carries a Zip64 extra field. +function buildDataDescriptor64(crc, compressedSize, uncompressedSize) { + const buffer = Buffer.allocUnsafe(24); + buffer.writeUInt32LE(SIG_DATA_DESCRIPTOR, 0); + buffer.writeUInt32LE(crc, 4); + writeSafeUint64(buffer, 8, compressedSize); + writeSafeUint64(buffer, 16, uncompressedSize); + return buffer; +} + +// Build the end of central directory record (sec. 4.3.16); fields that +// overflow their 16/32-bit slots are written as sentinels and the true values +// live in the Zip64 EOCD record. +function buildEndOfCentralDirectory(count, size, offset, comment) { + const buffer = Buffer.allocUnsafe(22 + comment.length); + buffer.writeUInt32LE(SIG_EOCD, 0); + buffer.writeUInt16LE(0, 4); // disk number + buffer.writeUInt16LE(0, 6); // Central directory disk number + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 8); + buffer.writeUInt16LE(MathMin(count, SENTINEL16), 10); + buffer.writeUInt32LE(MathMin(size, SENTINEL32), 12); + buffer.writeUInt32LE(MathMin(offset, SENTINEL32), 16); + buffer.writeUInt16LE(comment.length, 20); + comment.copy(buffer, 22); + return buffer; +} + +// Build the Zip64 end of central directory record (sec. 4.3.14): the 64-bit +// counterpart to the EOCD, holding the real record count/size/offset. +function buildZip64EndRecord(count, size, offset) { + const buffer = Buffer.allocUnsafe(56); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_RECORD, 0); + writeSafeUint64(buffer, 4, 44); // Size of the remainder of this record + buffer.writeUInt16LE((MADE_BY_UNIX << 8) | VERSION_ZIP64, 12); + buffer.writeUInt16LE(VERSION_ZIP64, 14); + buffer.writeUInt32LE(0, 16); // disk number + buffer.writeUInt32LE(0, 20); // Central directory disk number + writeSafeUint64(buffer, 24, count); + writeSafeUint64(buffer, 32, count); + writeSafeUint64(buffer, 40, size); + writeSafeUint64(buffer, 48, offset); + return buffer; +} + +// Build the Zip64 end of central directory locator (sec. 4.3.15): points the +// reader from just before the EOCD to the Zip64 EOCD record. +function buildZip64EndLocator(recordOffset) { + const buffer = Buffer.allocUnsafe(20); + buffer.writeUInt32LE(SIG_ZIP64_EOCD_LOCATOR, 0); + buffer.writeUInt32LE(0, 4); // Disk with the Zip64 EOCD record + writeSafeUint64(buffer, 8, recordOffset); + buffer.writeUInt32LE(1, 16); // total disks + return buffer; +} + +// The archive trailer: the Zip64 EOCD record and locator +// (sec. 4.3.14/4.3.15) when the record count, central directory offset, or +// central directory size overflows its classic 16-/32-bit field, followed +// by the end of central directory record (sec. 4.3.16). Shared by the +// streaming serializers and ZipFile's in-place rewrite so the Zip64 +// switchover rule lives in exactly one place. +function buildArchiveTrailer(count, centralDirectorySize, centralDirectoryOffset, comment) { + const chunks = []; + const zip64 = + count >= SENTINEL16 || + centralDirectoryOffset >= SENTINEL32 || + centralDirectorySize >= SENTINEL32; + if (zip64) { + const recordOffset = centralDirectoryOffset + centralDirectorySize; + ArrayPrototypePush(chunks, buildZip64EndRecord(count, centralDirectorySize, centralDirectoryOffset)); + ArrayPrototypePush(chunks, buildZip64EndLocator(recordOffset)); + } + ArrayPrototypePush(chunks, + buildEndOfCentralDirectory(count, centralDirectorySize, centralDirectoryOffset, comment)); + return chunks; +} + +module.exports = { + buildLocalHeader, + buildCentralHeader, + buildDataDescriptor64, + buildEndOfCentralDirectory, + buildZip64EndRecord, + buildZip64EndLocator, + buildArchiveTrailer, +}; diff --git a/lib/internal/zip/headers.js b/lib/internal/zip/headers.js new file mode 100644 index 000000000000..aadfa1ca3cde --- /dev/null +++ b/lib/internal/zip/headers.js @@ -0,0 +1,511 @@ +'use strict'; + +// Reader-side header structures (sec. 4.3): the end-of-central-directory +// record, the Zip64 EOCD record/locator, and the central/local file headers, +// plus `findArchiveEnd()` which locates them and `readCentralDirectory()` +// which walks the central directory into an array of headers. + +const { + ArrayPrototypePush, + MathMax, + Number, +} = primordials; + +const { + codes: { + ERR_ZIP_INVALID_ARCHIVE, + ERR_ZIP_UNSUPPORTED_FEATURE, + }, +} = require('internal/errors'); +const { + BIGINT_MAX_SAFE_INTEGER, + SIG_LOCAL_FILE_HEADER, + SIG_CENTRAL_FILE_HEADER, + SIG_ZIP64_EOCD_RECORD, + SIG_ZIP64_EOCD_LOCATOR, + SIG_EOCD, + MADE_BY_UNIX, + SENTINEL16, + SENTINEL32, + ZIP64_EOCD_MAX_LENGTH, + S_IFLNK, + S_IFMT, +} = require('internal/zip/constants'); +const { + validateArchiveRange, + readSafeUint64, +} = require('internal/zip/binary'); +const { parseZip64Extra } = require('internal/zip/extra-fields'); +const { + decodeDosDateTime, + decodeZipName, + decodeZipText, +} = require('internal/zip/dos'); + +// End of central directory record (sec. 4.3.16). +// Offset Bytes Description +// 0 4 Signature = 0x06054b50 +// 4 2 Number of this disk +// 6 2 Disk where central directory starts +// 8 2 Number of central directory records on this disk +// 10 2 Total number of central directory records +// 12 4 Size of central directory (bytes) +// 16 4 Offset of start of central directory +// 20 2 Comment length (n) +// 22 n Comment +class CentralEndHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 22, 'end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_EOCD) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('end of central directory record is truncated'); + } + } + get byteLength() { return 22 + this.commentLength; } + get diskNumber() { return this.#buffer.readUInt16LE(this.#offset + 4); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get centralDirectoryDiskRecords() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get centralDirectoryTotalRecords() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get centralDirectorySize() { return this.#buffer.readUInt32LE(this.#offset + 12); } + get centralDirectoryOffset() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get commentLength() { return this.#buffer.readUInt16LE(this.#offset + 20); } + get commentBuffer() { + const start = this.#offset + 22; + return this.#buffer.subarray(start, start + this.commentLength); + } +} + +// Zip64 end of central directory record (sec. 4.3.14). +// 0 4 Signature = 0x06064b50 +// 4 8 Size of remainder of this record +// 12 2 Version made by +// 14 2 Version needed to extract +// 16 4 Number of this disk +// 20 4 Disk where central directory starts +// 24 8 Number of central directory records on this disk +// 32 8 Total number of central directory records +// 40 8 Size of central directory +// 48 8 Offset of start of central directory +class Zip64EndRecord { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 56, 'Zip64 end of central directory record'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_RECORD) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get diskNumber() { return this.#buffer.readUInt32LE(this.#offset + 16); } + get centralDirectoryDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 20); } + get centralDirectoryDiskRecords() { return readSafeUint64(this.#buffer, this.#offset + 24); } + get centralDirectoryTotalRecords() { return readSafeUint64(this.#buffer, this.#offset + 32); } + get centralDirectorySize() { return readSafeUint64(this.#buffer, this.#offset + 40); } + get centralDirectoryOffset() { return readSafeUint64(this.#buffer, this.#offset + 48); } +} + +// Zip64 end of central directory locator (sec. 4.3.15). +// 0 4 Signature = 0x07064b50 +// 4 4 Disk with the Zip64 end of central directory record +// 8 8 Offset of the Zip64 end of central directory record +// 16 4 Total number of disks +class Zip64EndLocator { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 20, 'Zip64 end of central directory locator'); + if (buffer.readUInt32LE(offset) !== SIG_ZIP64_EOCD_LOCATOR) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 end of central directory locator signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + } + get recordDiskNumber() { return this.#buffer.readUInt32LE(this.#offset + 4); } + // Spec field (sec. 4.3.15); not consumed as a getter - findArchiveEnd() + // reads the offset leniently via readBigUInt64LE instead, so a locator + // signature that is really comment data cannot turn into a hard parse + // error on an out-of-range value. + // get recordOffset() { return readSafeUint64(this.#buffer, this.#offset + 8); } + get totalDisks() { return this.#buffer.readUInt32LE(this.#offset + 16); } +} + +// Central directory file header (sec. 4.3.12). +// 0 4 Signature = 0x02014b50 +// 4 2 Version made by +// 6 2 Version needed to extract +// 8 2 General purpose bit flag +// 10 2 Compression method +// 12 2 Last modification time +// 14 2 Last modification date +// 16 4 CRC-32 +// 20 4 Compressed size +// 24 4 Uncompressed size +// 28 2 File name length (n) +// 30 2 Extra field length (m) +// 32 2 File comment length (k) +// 34 2 Disk number where file starts +// 36 2 Internal file attributes +// 38 4 External file attributes +// 42 4 Relative offset of local file header +// 46 n File name +// 46+n m Extra field +// 46+n+m k File comment +class CentralFileHeader { + #buffer; + #offset; + #zip64 = null; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 46, 'central directory header'); + if (buffer.readUInt32LE(offset) !== SIG_CENTRAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory header is truncated'); + } + } + get byteOffset() { return this.#offset; } + get byteLength() { + return 46 + this.fileNameLength + this.extraFieldLength + this.fileCommentLength; + } + get version() { return this.#buffer.readUInt16LE(this.#offset + 4); } + // Spec field "version needed to extract" (sec. 4.4.3); not consumed today. + // get versionNeeded() { return this.#buffer.readUInt16LE(this.#offset + 6); } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 10); } + get lastModified() { + return decodeDosDateTime( + this.#buffer.readUInt16LE(this.#offset + 12), + this.#buffer.readUInt16LE(this.#offset + 14)); + } + get crc32() { return this.#buffer.readUInt32LE(this.#offset + 16); } + // Lazily parse the Zip64 extended-information extra field (sec. 4.5.3), + // supplying the true 64-bit values only for the classic fields that hold an + // overflow sentinel. Cached across getters. + #resolveZip64() { + if (this.#zip64 === null) { + this.#zip64 = parseZip64Extra(this.extraField, { + uncompressedSize: this.#buffer.readUInt32LE(this.#offset + 24) === SENTINEL32, + compressedSize: this.#buffer.readUInt32LE(this.#offset + 20) === SENTINEL32, + localFileHeaderOffset: this.#buffer.readUInt32LE(this.#offset + 42) === SENTINEL32, + diskNumber: this.#buffer.readUInt16LE(this.#offset + 34) === SENTINEL16, + }); + } + return this.#zip64; + } + get compressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 20); + return value === SENTINEL32 ? this.#resolveZip64().compressedSize : value; + } + get uncompressedSize() { + const value = this.#buffer.readUInt32LE(this.#offset + 24); + return value === SENTINEL32 ? this.#resolveZip64().uncompressedSize : value; + } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 30); } + get fileCommentLength() { return this.#buffer.readUInt16LE(this.#offset + 32); } + get diskNumber() { + const value = this.#buffer.readUInt16LE(this.#offset + 34); + return value === SENTINEL16 ? this.#resolveZip64().diskNumber : value; + } + get internalFileAttributes() { return this.#buffer.readUInt16LE(this.#offset + 36); } + get externalFileAttributes() { return this.#buffer.readUInt32LE(this.#offset + 38); } + get localFileHeaderOffset() { + const value = this.#buffer.readUInt32LE(this.#offset + 42); + return value === SENTINEL32 ? this.#resolveZip64().localFileHeaderOffset : value; + } + get fileNameBuffer() { + const start = this.#offset + 46; + return this.#buffer.subarray(start, start + this.fileNameLength); + } + get fileName() { return decodeZipName(this.fileNameBuffer, this.flags, this.extraField); } + get extraField() { + const start = this.#offset + 46 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + get fileCommentBuffer() { + const start = this.#offset + 46 + this.fileNameLength + this.extraFieldLength; + return this.#buffer.subarray(start, start + this.fileCommentLength); + } + get fileComment() { return decodeZipText(this.fileCommentBuffer, this.flags); } + get isUnixMode() { return (this.version >>> 8) === MADE_BY_UNIX; } + get mode() { + // Low 12 bits: permissions plus setuid/setgid/sticky (sec. 4.4.15). + return this.isUnixMode ? (this.externalFileAttributes >>> 16) & 0o7777 : 0; + } + get isSymlink() { + return this.isUnixMode && + ((this.externalFileAttributes >>> 16) & S_IFMT) === S_IFLNK; + } +} + +// Local file header (sec. 4.3.7). +// 0 4 Signature = 0x04034b50 +// 4 2 Version needed to extract +// 6 2 General purpose bit flag +// 8 2 Compression method +// 10 2 Last modification time +// 12 2 Last modification date +// 14 4 CRC-32 +// 18 4 Compressed size +// 22 4 Uncompressed size +// 26 2 File name length (n) +// 28 2 Extra field length (m) +// 30 n File name +// 30+n m Extra field +class LocalFileHeader { + #buffer; + #offset; + constructor(buffer, offset = 0) { + validateArchiveRange(buffer, offset, 30, 'local file header'); + if (buffer.readUInt32LE(offset) !== SIG_LOCAL_FILE_HEADER) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header signature is invalid'); + } + this.#buffer = buffer; + this.#offset = offset; + if (offset + this.byteLength > buffer.length) { + throw new ERR_ZIP_INVALID_ARCHIVE('local file header is truncated'); + } + } + get byteLength() { return 30 + this.fileNameLength + this.extraFieldLength; } + get flags() { return this.#buffer.readUInt16LE(this.#offset + 6); } + // Spec field (sec. 4.4.5); the central directory's method is authoritative, + // so the local copy is not consumed today. + // get compressionMethod() { return this.#buffer.readUInt16LE(this.#offset + 8); } + get fileNameLength() { return this.#buffer.readUInt16LE(this.#offset + 26); } + get extraFieldLength() { return this.#buffer.readUInt16LE(this.#offset + 28); } + get extraField() { + const start = this.#offset + 30 + this.fileNameLength; + return this.#buffer.subarray(start, start + this.extraFieldLength); + } + // The local header also carries the name and modification time (sec. 4.3.7), + // but the central directory is authoritative for both - a mismatching local + // name is ignored by design - so only the flags and the extra field (which + // may hold a higher-resolution timestamp) are consumed here. + // get fileName() { + // const start = this.#offset + 30; + // return decodeZipName( + // this.#buffer.subarray(start, start + this.fileNameLength), this.flags, this.extraField); + // } + // get lastModified() { + // return decodeDosDateTime(this.#buffer.readUInt16LE(this.#offset + 10), + // this.#buffer.readUInt16LE(this.#offset + 12)); + // } + // Total on-disk length of the local header at `offset` (sec. 4.3.7), read + // straight from the length fields without constructing a header; 0 if the + // fixed part does not fit. Lets the reader skip to the entry data. + static length(buffer, offset) { + if (offset + 30 > buffer.length) return 0; + return 30 + buffer.readUInt16LE(offset + 26) + buffer.readUInt16LE(offset + 28); + } +} + +/** + * Locates and validates the end-of-archive structures (EOCD, and the Zip64 + * EOCD locator/record when present) in `buffer`. `base` is the absolute + * offset of `buffer[0]` when `buffer` is only the tail of a larger file; all + * returned offsets are absolute. `buffer` must extend to the end of the + * archive. + * + * When a required Zip64 EOCD record starts before `buffer[0]` (its + * extensible data sector can push it beyond any fixed-size tail read), + * returns `{ needTailFrom }` instead: the caller must retry with a buffer + * that starts at that absolute offset (still ending at the end of the + * archive). Never returned when `base` is 0. + * @returns {{ + * prefix: number, + * totalRecords: number, + * centralDirectoryOffset: number, + * centralDirectorySize: number, + * comment: Buffer, + * } | { needTailFrom: number }} + */ +function findArchiveEnd(buffer, base = 0) { + if (buffer.length < 22) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const min = MathMax(0, buffer.length - (22 + SENTINEL16)); + let eocdPos = -1; + // Pass 1: the comment must reach exactly to the end of the buffer (this + // rejects a stray EOCD-looking signature inside an earlier comment). + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) !== buffer.length) continue; + eocdPos = pos; + break; + } + if (eocdPos < 0) { + // Pass 2: tolerate trailing padding after the EOCD (some streaming + // writers pad their output to a fixed block size); take the last + // candidate found. + for (let pos = buffer.length - 22; pos >= min; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_EOCD) continue; + if (pos + 22 + buffer.readUInt16LE(pos + 20) > buffer.length) continue; + eocdPos = pos; + break; + } + } + if (eocdPos < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('no end of central directory record found'); + } + const eocd = new CentralEndHeader(buffer, eocdPos); + let totalRecords = eocd.centralDirectoryTotalRecords; + let centralDirectorySize = eocd.centralDirectorySize; + let centralDirectoryOffset = eocd.centralDirectoryOffset; + let prefix; + // A classic field at its maximum is an overflow sentinel that makes the + // Zip64 record mandatory. Otherwise the classic fields are authoritative, + // and a Zip64 locator signature in the preceding bytes is not proof of a + // Zip64 archive - it may be the tail of a file comment that happens to + // contain those four bytes - so a failed Zip64 lookup falls back to the + // classic fields instead of rejecting the archive. + const needsZip64 = + eocd.diskNumber === SENTINEL16 || + eocd.centralDirectoryDiskNumber === SENTINEL16 || + eocd.centralDirectoryDiskRecords === SENTINEL16 || + totalRecords === SENTINEL16 || + centralDirectorySize === SENTINEL32 || + centralDirectoryOffset === SENTINEL32; + let zip64 = null; + let recordPos = -1; + const locatorPos = eocdPos - 20; + if ( + locatorPos >= 0 && + buffer.readUInt32LE(locatorPos) === SIG_ZIP64_EOCD_LOCATOR + ) { + const locator = new Zip64EndLocator(buffer, locatorPos); + // Read the recorded offset leniently: on a coincidental signature these + // eight bytes are arbitrary comment data, which must not turn into a + // hard parse error. + const rawRecordOffset = buffer.readBigUInt64LE(locatorPos + 8); + const recordOffset = + rawRecordOffset <= BIGINT_MAX_SAFE_INTEGER ? Number(rawRecordOffset) : -1; + recordPos = recordOffset >= 0 ? recordOffset - base : -1; + if ( + !(recordPos >= 0 && + recordPos + 56 <= locatorPos && + buffer.readUInt32LE(recordPos) === SIG_ZIP64_EOCD_RECORD) + ) { + // Data was prepended to the archive, shifting the record; scan + // backward from the locator instead of trusting its recorded offset. + recordPos = -1; + const floor = MathMax(0, locatorPos - 56 - SENTINEL16); + for (let pos = locatorPos - 56; pos >= floor; pos--) { + if (buffer.readUInt32LE(pos) !== SIG_ZIP64_EOCD_RECORD) continue; + const size = buffer.readBigUInt64LE(pos + 4); + if (size >= 44n && pos + 12 + Number(size) === locatorPos) { + recordPos = pos; + break; + } + } + } + if (recordPos < 0 && needsZip64) { + // The record is required but does not lie inside `buffer`: its + // extensible data sector may extend it beyond any fixed-size tail + // read. When the locator points (plausibly) before the bytes at + // hand, ask the caller for a longer tail instead of failing. + if ( + recordOffset >= 0 && + recordOffset < base && + base + locatorPos - recordOffset <= ZIP64_EOCD_MAX_LENGTH + ) { + return { needTailFrom: recordOffset }; + } + throw new ERR_ZIP_INVALID_ARCHIVE('Zip64 end of central directory record not found'); + } + if (recordPos >= 0) { + if (locator.totalDisks > 1 || locator.recordDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + zip64 = new Zip64EndRecord(buffer, recordPos); + } + } + // The `prefix` math assumes nothing sits between the central directory and + // the archive-end records. APPNOTE sec. 4.3.13 allows a digital-signature + // record there; such an archive shifts `prefix` by the signature's length + // and fails with a central-directory signature error rather than a targeted + // message - signed archives are essentially extinct, so none is emitted. + if (zip64 !== null) { + if (zip64.diskNumber !== 0 || zip64.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (zip64.centralDirectoryDiskRecords !== zip64.centralDirectoryTotalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + // A classic field either holds the overflow sentinel (its real value lives + // in the Zip64 record) or its own real value, which must then match the + // Zip64 record. A non-sentinel classic field that disagrees with Zip64 is a + // parser differential - a classic-only reader and this one would see + // different archives - so reject it rather than silently preferring Zip64. + if ( + (totalRecords !== SENTINEL16 && + totalRecords !== zip64.centralDirectoryTotalRecords) || + (centralDirectorySize !== SENTINEL32 && + centralDirectorySize !== zip64.centralDirectorySize) || + (centralDirectoryOffset !== SENTINEL32 && + centralDirectoryOffset !== zip64.centralDirectoryOffset) + ) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'Zip64 and classic end-of-central-directory records disagree'); + } + totalRecords = zip64.centralDirectoryTotalRecords; + centralDirectorySize = zip64.centralDirectorySize; + centralDirectoryOffset = zip64.centralDirectoryOffset; + prefix = base + recordPos - (centralDirectoryOffset + centralDirectorySize); + } else { + if (eocd.diskNumber !== 0 || eocd.centralDirectoryDiskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + if (eocd.centralDirectoryDiskRecords !== totalRecords) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + prefix = base + eocdPos - (centralDirectoryOffset + centralDirectorySize); + } + if (prefix < 0) { + throw new ERR_ZIP_INVALID_ARCHIVE('central directory does not fit inside the archive'); + } + if (totalRecords * 46 > centralDirectorySize) { + throw new ERR_ZIP_INVALID_ARCHIVE( + 'central directory record count is inconsistent with its size'); + } + return { + prefix, + totalRecords, + centralDirectoryOffset: centralDirectoryOffset + prefix, + centralDirectorySize, + comment: eocd.commentBuffer, + }; +} + +// Walk the contiguous run of `count` central directory file headers +// (sec. 4.3.12) into an array, rejecting multi-disk archives. +function readCentralDirectory(buffer, count) { + const result = []; + let pos = 0; + for (let index = 0; index < count; index++) { + const header = new CentralFileHeader(buffer, pos); + if (header.diskNumber !== 0) { + throw new ERR_ZIP_UNSUPPORTED_FEATURE('multi-disk archives are not supported'); + } + ArrayPrototypePush(result, header); + pos += header.byteLength; + } + return result; +} + +module.exports = { + CentralFileHeader, + LocalFileHeader, + findArchiveEnd, + readCentralDirectory, +}; diff --git a/lib/zlib.js b/lib/zlib.js index e6c5c420d551..29c61b51fc6f 100644 --- a/lib/zlib.js +++ b/lib/zlib.js @@ -31,7 +31,9 @@ const { ObjectFreeze, ObjectKeys, ObjectSetPrototypeOf, + ReflectApply, Symbol, + SymbolHasInstance, Uint32Array, Uint8Array, } = primordials; @@ -51,6 +53,7 @@ const { Transform, finished } = require('stream'); const { assignFunctionName, deprecateInstantiation, + emitExperimentalWarning, } = require('internal/util'); const { isArrayBufferView, @@ -73,6 +76,16 @@ const { validateFiniteNumber, } = require('internal/validators'); const { FastBuffer } = require('internal/buffer'); +const { + ZipEntry, + ZipFile, + ZipBuffer, + createZipArchive, + createZipArchiveSync, + zipFiles, + getMaxZipContentSize, + setMaxZipContentSize, +} = require('internal/zip'); const kFlushFlag = Symbol('kFlushFlag'); const kError = Symbol('kError'); @@ -995,6 +1008,62 @@ function createProperty(ctor) { }; } +// ZIP archive support is experimental. The warning fires when the API is +// *used*, not when node:zlib is imported: the ESM facade reads every export to +// build its bindings (see BuiltinModule.syncExports), so warning on property +// access would fire on a bare `import 'node:zlib'`. Each public class is a thin +// subclass whose factory methods (and, for ZipBuffer, its constructor) warn; +// each function is a warn-on-call wrapper. A `[Symbol.hasInstance]` override +// keeps `instanceof` matching instances produced by the internal (unwrapped) +// implementation. The rest of node:zlib is stable and never warns. +function emitZipExperimentalWarning() { + emitExperimentalWarning('The zlib ZIP archive API'); +} + +function experimentalZipFunction(fn, thisArg) { + return function(...args) { + emitZipExperimentalWarning(); + return ReflectApply(fn, thisArg, args); + }; +} + +function experimentalZipProperty(value) { + return { __proto__: null, configurable: true, enumerable: true, value }; +} + +// Thin subclasses that gate *use* of the experimental ZIP API behind the +// warning while leaving `instanceof` (and the public class name) intact. +class ExperimentalZipEntry extends ZipEntry { + static [SymbolHasInstance](instance) { return instance instanceof ZipEntry; } +} + +class ExperimentalZipFile extends ZipFile { + static [SymbolHasInstance](instance) { return instance instanceof ZipFile; } +} + +class ExperimentalZipBuffer extends ZipBuffer { + static [SymbolHasInstance](instance) { return instance instanceof ZipBuffer; } + constructor(buffer) { emitZipExperimentalWarning(); super(buffer); } +} + +// Shadow each public factory with a warn-on-call wrapper, and restore the +// public class name that subclassing changed. +for (const { 0: Wrapper, 1: Raw, 2: factories } of [ + [ExperimentalZipEntry, ZipEntry, ['read', 'create', 'createSync', 'createStream', 'createSymlink']], + [ExperimentalZipFile, ZipFile, ['open', 'openSync']], + [ExperimentalZipBuffer, ZipBuffer, []], +]) { + for (const name of factories) { + ObjectDefineProperty(Wrapper, name, { + __proto__: null, + configurable: true, + writable: true, + value: experimentalZipFunction(Raw[name], Raw), + }); + } + ObjectDefineProperty(Wrapper, 'name', { __proto__: null, value: Raw.name }); +} + function crc32(data, value = 0) { if (typeof data !== 'string' && !isArrayBufferView(data)) { throw new ERR_INVALID_ARG_TYPE('data', ['Buffer', 'TypedArray', 'DataView', 'string'], data); @@ -1077,6 +1146,16 @@ ObjectDefineProperties(module.exports, { writable: false, value: ObjectFreeze(codes), }, + + // ZIP archive support (experimental). + ZipEntry: experimentalZipProperty(ExperimentalZipEntry), + ZipFile: experimentalZipProperty(ExperimentalZipFile), + ZipBuffer: experimentalZipProperty(ExperimentalZipBuffer), + createZipArchive: experimentalZipProperty(experimentalZipFunction(createZipArchive)), + createZipArchiveSync: experimentalZipProperty(experimentalZipFunction(createZipArchiveSync)), + zipFiles: experimentalZipProperty(experimentalZipFunction(zipFiles)), + getMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(getMaxZipContentSize)), + setMaxZipContentSize: experimentalZipProperty(experimentalZipFunction(setMaxZipContentSize)), }); // These should be considered deprecated diff --git a/test/parallel/test-zlib-zip-coverage.js b/test/parallel/test-zlib-zip-coverage.js new file mode 100644 index 000000000000..437630df95cd --- /dev/null +++ b/test/parallel/test-zlib-zip-coverage.js @@ -0,0 +1,1318 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const tmpdir = require('../common/tmpdir'); +const fs = require('node:fs/promises'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +// Additional zip.js coverage beyond the other test-zlib-zip-*.js files: +// DOS date/time edge cases, the Zip64 extra-field parser's normal and +// out-of-range paths, the "data was prepended to the archive" central +// directory recovery scan, buffer-coercion variants, entry-metadata +// validation, streaming-entry state guards, the ZipBuffer/ZipFile +// iteration protocols, and several ZipFile (on-disk) error paths that +// aren't reachable through ZipBuffer alone. + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function drain(iterable) { + const chunks = []; + for await (const chunk of iterable) chunks.push(chunk); + return Buffer.concat(chunks); +} + +// -- DOS date/time ----------------------------------------------------------- + +test('a zeroed DOS date/time field decodes to the 1980-01-01 epoch', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(0, 10); // local time + tampered.writeUInt16LE(0, 12); // local date + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt16LE(0, centralStart + 12); // central time + tampered.writeUInt16LE(0, centralStart + 14); // central date + + const [read] = zlib.ZipEntry.read(tampered); + assert.strictEqual(read.modified.getTime(), new Date(1980, 0, 1, 0, 0, 0).getTime()); +}); + +test('serializing an entry with an invalid modified Date is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { modified: new Date(NaN) }); + await assert.rejects(buildArchive([entry]), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +// -- Zip64 structures --------------------------------------------------------- + +function buildZip64Record({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0n, + cdTotalRecords = 0n, cdSize = 0n, cdOffset = 0n } = {}) { + const buf = Buffer.allocUnsafe(56); + buf.writeUInt32LE(0x06064b50, 0); + buf.writeBigUInt64LE(44n, 4); + buf.writeUInt16LE((3 << 8) | 45, 12); // Made by Unix, version 4.5 + buf.writeUInt16LE(45, 14); + buf.writeUInt32LE(diskNumber, 16); + buf.writeUInt32LE(cdDiskNumber, 20); + buf.writeBigUInt64LE(cdDiskRecords, 24); + buf.writeBigUInt64LE(cdTotalRecords, 32); + buf.writeBigUInt64LE(cdSize, 40); + buf.writeBigUInt64LE(cdOffset, 48); + return buf; +} + +function buildZip64Locator({ recordDiskNumber = 0, recordOffset = 0n, totalDisks = 1 } = {}) { + const buf = Buffer.allocUnsafe(20); + buf.writeUInt32LE(0x07064b50, 0); + buf.writeUInt32LE(recordDiskNumber, 4); + buf.writeBigUInt64LE(recordOffset, 8); + buf.writeUInt32LE(totalDisks, 16); + return buf; +} + +function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0, + totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) { + const buf = Buffer.allocUnsafe(22 + comment.length); + buf.writeUInt32LE(0x06054b50, 0); + buf.writeUInt16LE(diskNumber, 4); + buf.writeUInt16LE(cdDiskNumber, 6); + buf.writeUInt16LE(cdDiskRecords, 8); + buf.writeUInt16LE(totalRecords, 10); + buf.writeUInt32LE(cdSize, 12); + buf.writeUInt32LE(cdOffset, 16); + buf.writeUInt16LE(comment.length, 20); + comment.copy(buf, 22); + return buf; +} + +// A minimal (zero-entry) Zip64 archive: record + locator + classic EOCD, +// with the locator pointing directly at the record. +function buildMinimalZip64Archive({ record, locator, eocd } = {}) { + return Buffer.concat([ + buildZip64Record(record), + buildZip64Locator({ recordOffset: 0n, ...locator }), + buildEocd(eocd), + ]); +} + +test('a well-formed minimal Zip64 archive round-trips its comment', () => { + const buf = buildMinimalZip64Archive({ eocd: { comment: Buffer.from('hi') } }); + const zip = new zlib.ZipBuffer(buf); + assert.strictEqual(zip.size, 0); + assert.strictEqual(zip.comment, 'hi'); +}); + +test('a Zip64 locator declaring more than one disk is rejected', () => { + const buf = buildMinimalZip64Archive({ locator: { totalDisks: 2 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 locator pointing at another disk is rejected', () => { + const buf = buildMinimalZip64Archive({ locator: { recordDiskNumber: 1 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record on another disk is rejected', () => { + const buf = buildMinimalZip64Archive({ record: { diskNumber: 1 } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record with inconsistent disk record counts is rejected', () => { + const buf = buildMinimalZip64Archive({ record: { cdDiskRecords: 1n, cdTotalRecords: 2n } }); + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 record is found by scanning backward when data was prepended', () => { + // The locator's declared offset is wrong (as if the archive had been + // prepended with extra bytes, e.g. a self-extractor stub, after the + // record/locator were written), but the record still physically sits + // immediately before the locator; findArchiveEnd() must recover it. + const buf = buildMinimalZip64Archive({ + locator: { recordOffset: 999_999n }, + eocd: { comment: Buffer.from('recovered') }, + }); + const zip = new zlib.ZipBuffer(buf); + assert.strictEqual(zip.comment, 'recovered'); +}); + +test('a required Zip64 record that cannot be found anywhere is rejected', () => { + // An EOCD overflow sentinel makes the Zip64 record mandatory; with the + // record's signature corrupted and the locator pointing nowhere useful, + // the archive is unreadable. + const buf = buildMinimalZip64Archive({ + locator: { recordOffset: 999_999n }, + eocd: { totalRecords: 0xffff, cdDiskRecords: 0xffff }, + }); + buf.writeUInt32LE(0xdeadbeef, 0); // Also corrupt the record actually present + assert.throws(() => [...zlib.ZipEntry.read(buf)], { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /Zip64 end of central directory record not found/, + }); +}); + +test('a missing Zip64 record falls back to valid classic EOCD fields', () => { + // Same corrupted record, but no EOCD field carries an overflow sentinel: + // the classic fields fully describe the (empty) archive, so the stray + // locator signature - indistinguishable from comment bytes that happen to + // contain it - must not reject an otherwise valid archive. + const buf = buildMinimalZip64Archive({ locator: { recordOffset: 999_999n } }); + buf.writeUInt32LE(0xdeadbeef, 0); + assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0); +}); + +// Patches a single-entry, single-record archive's central header to carry a +// synthetic Zip64 extra field for whichever of {uncompressedSize, +// compressedSize, localFileHeaderOffset, diskNumber} are provided, setting +// the corresponding 32-/16-bit field to the sentinel value that tells +// CentralFileHeader to resolve it from the extra field instead. A leading, +// unrelated TLV record is always included ahead of the Zip64 one, so the +// "skip past a foreign record" loop iteration is exercised too. +function injectZip64Extra(archive, name, contentLength, fields) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); // sanity: no existing extra + + const dummyTlv = Buffer.from([0x99, 0x99, 0x04, 0x00, 0xde, 0xad, 0xbe, 0xef]); + const order = ['uncompressedSize', 'compressedSize', 'localFileHeaderOffset', 'diskNumber']; + const dataLength = order.reduce( + (total, key) => total + (key in fields ? (key === 'diskNumber' ? 4 : 8) : 0), 0); + const zip64Tlv = Buffer.allocUnsafe(4 + dataLength); + zip64Tlv.writeUInt16LE(0x0001, 0); + zip64Tlv.writeUInt16LE(dataLength, 2); + let pos = 4; + for (const key of order) { + if (!(key in fields)) continue; + if (key === 'diskNumber') { + zip64Tlv.writeUInt32LE(Number(fields[key]), pos); + pos += 4; + } else { + zip64Tlv.writeBigUInt64LE(BigInt(fields[key]), pos); + pos += 8; + } + } + const extra = Buffer.concat([dummyTlv, zip64Tlv]); + + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); // comment + EOCD + const patched = Buffer.concat([before, extra, after]); + + patched.writeUInt16LE(extra.length, extraLengthOffset); + if ('uncompressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24); + if ('compressedSize' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); + if ('localFileHeaderOffset' in fields) patched.writeUInt32LE(0xffffffff, centralHeaderStart + 42); + if ('diskNumber' in fields) patched.writeUInt16LE(0xffff, centralHeaderStart + 34); + + const eocdOffset = patched.length - 22; + assert.strictEqual(patched.readUInt32LE(eocdOffset), 0x06054b50); + const oldCdSize = patched.readUInt32LE(eocdOffset + 12); + patched.writeUInt32LE(oldCdSize + extra.length, eocdOffset + 12); + + return patched; +} + +test('a foreign Zip64 extra field naming only the fields it needs still resolves', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const patched = injectZip64Extra(archive, name, content.length, { + uncompressedSize: content.length, + compressedSize: content.length, + localFileHeaderOffset: 0, + diskNumber: 0, + }); + + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.size, content.length); + assert.strictEqual(read.compressedSize, content.length); + assert.strictEqual((await read.content()).toString(), 'hello'); +}); + +test('a foreign Zip64 extra field carrying every field regardless of sentinels still resolves', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + // APPNOTE 4.5.3 says Zip64 fields MUST appear only for the classic fields + // holding the overflow sentinel, but plenty of real writers emit all four + // regardless. Only the compressed size is a sentinel here, so a strictly + // packed parse would misread the uncompressed-size slot as the compressed + // size; the parser must fall back to the full fixed layout instead. + const centralHeaderStart = 30 + name.length + content.length; + const nameStart = centralHeaderStart + 46; + const tlv = Buffer.allocUnsafe(4 + 28); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(28, 2); + tlv.writeBigUInt64LE(0x1111n, 4); // Uncompressed size (classic field wins) + tlv.writeBigUInt64LE(BigInt(content.length), 12); // Compressed size + tlv.writeBigUInt64LE(0n, 20); // Local header offset (classic field wins) + tlv.writeUInt32LE(0, 28); // Disk number (classic field wins) + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, tlv, after]); + patched.writeUInt16LE(tlv.length, centralHeaderStart + 30); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel only + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12); + + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.compressedSize, content.length); + assert.strictEqual(read.size, content.length); + assert.strictEqual((await read.content()).toString(), 'hello'); + + // Re-serialization drops the (now stale) Zip64 record - Zip64 data is + // regenerated from the final sizes - and the entry stays readable. + const rewritten = await buildArchive([read]); + const [reread] = zlib.ZipEntry.read(rewritten); + assert.strictEqual((await reread.content()).toString(), 'hello'); +}); + +test('a Zip64 extra field value beyond Number.MAX_SAFE_INTEGER is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const patched = injectZip64Extra(archive, name, content.length, { + uncompressedSize: 0xffffffffffffffffn, + }); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /exceeds the safe integer range/, + }); +}); + +// Injects a raw, already-built Zip64 extra-field TLV (rather than one built +// via injectZip64Extra()'s field map), to exercise the parser's own +// malformed/truncated-input rejections. +function injectRawZip64Extra(archive, name, contentLength, extraBytes) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, extraBytes, after]); + patched.writeUInt16LE(extraBytes.length, extraLengthOffset); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 24); // uncompressedSize sentinel + const eocdOffset = patched.length - 22; + const oldCdSize = patched.readUInt32LE(eocdOffset + 12); + patched.writeUInt32LE(oldCdSize + extraBytes.length, eocdOffset + 12); + return patched; +} + +test('a Zip64 extra-field TLV whose declared size overflows the extra field is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + const tlv = Buffer.allocUnsafe(8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(100, 2); // claims 100 bytes of data, but none follow + const patched = injectRawZip64Extra(archive, name, content.length, tlv); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /extra field is malformed/, + }); +}); + +test('a Zip64 extra-field TLV too short for the field it claims to carry is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = await buildArchive([entry]); + + // Declares only 4 bytes of data, but a sentinel uncompressedSize needs 8. + const tlv = Buffer.allocUnsafe(8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(4, 2); + tlv.writeUInt32LE(123, 4); + const patched = injectRawZip64Extra(archive, name, content.length, tlv); + + const [read] = zlib.ZipEntry.read(patched); + assert.throws(() => read.size, { + code: 'ERR_ZIP_INVALID_ARCHIVE', + message: /Zip64 extended information extra field is truncated/, + }); +}); + +// -- buffer coercion ----------------------------------------------------------- + +test('create() accepts a DataView, a non-Uint8Array TypedArray, and an ArrayBuffer', async () => { + const ab = new ArrayBuffer(4); + new Uint8Array(ab).set([1, 2, 3, 4]); + + const dv = new DataView(ab, 1, 2); + const fromDataView = await zlib.ZipEntry.create('dv.bin', dv); + assert.strictEqual((await fromDataView.content()).length, 2); + + const i32 = new Int32Array([10, 20, 30]); + const fromTypedArray = await zlib.ZipEntry.create('i32.bin', i32); + assert.strictEqual((await fromTypedArray.content()).length, 12); + + const fromArrayBuffer = await zlib.ZipEntry.create('ab.bin', ab); + assert.strictEqual((await fromArrayBuffer.content()).length, 4); +}); + +// -- entry-metadata validation ------------------------------------------------- + +test('create() validates comment length, modified type, and method value', async () => { + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { comment: 'x'.repeat(70000) }), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }, + ); + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { modified: 123 }), + { code: 'ERR_INVALID_ARG_TYPE' }, + ); + await assert.rejects( + zlib.ZipEntry.create('f.txt', Buffer.alloc(0), { method: 'bogus' }), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('a directory entry must have empty content, for create(), createSync(), and createStream()', async () => { + await assert.rejects( + zlib.ZipEntry.create('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => zlib.ZipEntry.createSync('dir/', Buffer.from('x')), { code: 'ERR_INVALID_ARG_VALUE' }); + assert.throws( + () => zlib.ZipEntry.createStream('dir/', (async function* () {})()), { code: 'ERR_INVALID_ARG_VALUE' }); +}); + +test('createZipArchive()/createZipArchiveSync() validate the archive comment length', async () => { + await assert.rejects(drain(zlib.createZipArchive([], 'x'.repeat(70000))), + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], 'x'.repeat(70000))], + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }); +}); + +// -- streaming-entry state guards ---------------------------------------------- + +test('a pending streaming entry rejects size/crc32/compressedSize/content access', () => { + const streaming = zlib.ZipEntry.createStream( + 'big.bin', (async function* () { yield Buffer.from('x'); })()); + assert.throws(() => streaming.size, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.crc32, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.compressedSize, { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.contentIterator(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => streaming.contentSync(), { code: 'ERR_INVALID_STATE' }); +}); + +// Once a streaming entry has been serialized on its own (via createZipArchive, +// not into a writable ZipFile that would promote it), its source is spent and +// there is nothing to read back. Reads must fail with a clean state error, not +// silently decode an empty buffer and report ERR_ZIP_ENTRY_CORRUPT. +test('a spent (serialized-but-unpromoted) streaming entry rejects reads cleanly', async () => { + const entry = zlib.ZipEntry.createStream('s.txt', (async function* () { yield Buffer.from('hello'); })()); + await drain(entry); + await assert.rejects(entry.content(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => entry.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => entry.contentIterator(), { code: 'ERR_INVALID_STATE' }); +}); + +test('a streaming entry can only be serialized once', async () => { + const entry = zlib.ZipEntry.createStream('a.bin', (async function* () { yield Buffer.from('x'); })()); + await drain(entry); + await assert.rejects(drain(entry), { code: 'ERR_INVALID_STATE' }); +}); + +test('a streaming entry rejects a non-Uint8Array chunk from its source', async () => { + async function* badSource() { + yield Buffer.alloc(0); // An empty chunk is silently skipped + yield 'not a buffer'; + } + const entry = zlib.ZipEntry.createStream('b.bin', badSource()); + await assert.rejects(drain(entry), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('a streaming entry can use zstd compression end-to-end', async () => { + const payload = 'zstd stream content '.repeat(50); + async function* source() { yield Buffer.from(payload); } + const entry = zlib.ZipEntry.createStream('c.bin', source(), { method: 'zstd' }); + const archive = await buildArchive([entry]); + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + assert.strictEqual((await read.content()).toString(), payload); +}); + +test('an error from a streaming entry\'s source propagates and cleans up (deflate and zstd)', async () => { + for (const method of ['deflate', 'zstd']) { + async function* badSource() { + yield Buffer.from('some data before the error'); + throw new Error(`source blew up (${method})`); + } + const entry = zlib.ZipEntry.createStream('big.bin', badSource(), { method }); + await assert.rejects(drain(entry), { message: `source blew up (${method})` }); + } +}); + +// -- contentIterator() / decodeMemberStream() error paths ------------------------ + +test('contentIterator() enforces the same guards as content() and contentSync()', async () => { + // Encrypted. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6); + const centralStart = 30 + 'f.txt'.length + 'secret'.length; + tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + } + // Unsupported compression method. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(1, 8); + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt16LE(1, centralStart + 10); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + } + // maxSize enforced up front. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(drain(entry.contentIterator({ maxSize: 1 })), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } + // Declared-size mismatch. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hello world'.length; + tampered.writeUInt32LE(1, centralStart + 24); + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + } + // CRC-32 mismatch, and disabling verification. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered[30 + 'f.txt'.length] ^= 0xff; + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + const unverified = await drain(read.contentIterator({ verify: false })); + assert.strictEqual(unverified.length, 'hello world'.length); + } +}); + +// -- content()/contentSync() zstd-specific branches ---------------------------- + +test('content() and contentSync() enforce maxSize and detect corruption for zstd entries', async () => { + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method: 'zstd' }); + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + await assert.rejects(read.content({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('z'.repeat(200)), { method: 'zstd' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered.fill(0xff, contentStart, contentStart + 4); // Break the zstd frame itself + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(read.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + assert.throws(() => read.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + } +}); + +// `decodeMemberSync()` (used by `ZipEntry.prototype.contentSync()`) duplicates +// `decodeMemberStream()`'s guards for its own, separate synchronous code +// path; exercise them through a disk-backed ZipFile. +test('ZipFile getSync().contentSync() enforces the same guards via decodeMemberSync()', async () => { + async function writeTempArchive(archive, suffix) { + const filePath = tmpdir.resolve(`zip-coverage-contentsync-${suffix}.zip`); + await fs.writeFile(filePath, archive); + return filePath; + } + + // Encrypted. + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + tampered.writeUInt16LE(tampered.readUInt16LE(6) | 0x0001, 6); + const centralStart = 30 + 'f.txt'.length + 'secret'.length; + tampered.writeUInt16LE(tampered.readUInt16LE(centralStart + 8) | 0x0001, centralStart + 8); + const filePath = await writeTempArchive(tampered, 'encrypted'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // maxSize, for both the deflate and the zstd decode branch. + for (const method of ['deflate', 'zstd']) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(5000)), { method }); + const archive = await buildArchive([entry]); + const filePath = await writeTempArchive(archive, `maxsize-${method}`); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync({ maxSize: 10 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // A genuine decompression failure (not just a CRC mismatch after a + // successful decode). + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('y'.repeat(500)), { method: 'deflate' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered.fill(0xff, contentStart, contentStart + 4); + const filePath = await writeTempArchive(tampered, 'deflate-corrupt'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + zf.closeSync(); + await fs.unlink(filePath); + } + // Declared-size mismatch ("produced N bytes, expected M"). + { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hello world'.length; + tampered.writeUInt32LE(1, centralStart + 24); + const filePath = await writeTempArchive(tampered, 'size-mismatch'); + const zf = zlib.ZipFile.openSync(filePath); + assert.throws(() => zf.getSync('f.txt').contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + zf.closeSync(); + await fs.unlink(filePath); + } +}); + +test('contentIterator() rejects an entry that inflates to less than its declared size', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const centralStart = 30 + 'f.txt'.length + 'hi'.length; + tampered.writeUInt32LE(1000, centralStart + 24); // Declared size grown beyond reality + const [read] = zlib.ZipEntry.read(tampered); + await assert.rejects(drain(read.contentIterator()), { + code: 'ERR_ZIP_ENTRY_CORRUPT', + message: /is truncated/, + }); +}); + +// -- ZipBuffer / ZipFile iteration protocols ----------------------------------- + +test('ZipBuffer exposes Map-like forEach/values/entries/iteration/toStringTag', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('1')), + await zlib.ZipEntry.create('b.txt', Buffer.from('2')), + ]); + const zip = new zlib.ZipBuffer(archive); + + const seen = []; + zip.forEach((entry, key, self) => { + seen.push(key); + assert.strictEqual(self, zip); + assert.strictEqual(entry.name, key); + }); + assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.txt']); + + assert.deepStrictEqual([...zip.values()].map((e) => e.name).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zip.entries()].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zip].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.strictEqual(Object.prototype.toString.call(zip), '[object ZipBuffer]'); + assert.throws(() => zip.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('ZipFile exposes the same iteration protocol, plus its Sync counterparts', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('1')), + await zlib.ZipEntry.create('b.txt', Buffer.from('2')), + ]); + const filePath = tmpdir.resolve('zip-coverage-iteration.zip'); + await fs.writeFile(filePath, archive); + + const zf = await zlib.ZipFile.open(filePath); + try { + const pending = []; + zf.forEach((valuePromise) => pending.push(valuePromise)); + await Promise.all(pending); // Let every dangling get() settle before closing + + zf.forEachSync(() => {}); + assert.deepStrictEqual([...zf.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zf.entriesSync()].map(([k]) => k).sort(), ['a.txt', 'b.txt']); + assert.deepStrictEqual([...zf.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(zf.size, 2); + assert.strictEqual(Object.prototype.toString.call(zf), '[object ZipFile]'); + + const names = []; + for await (const entry of zf) names.push(entry.name); + assert.deepStrictEqual(names.sort(), ['a.txt', 'b.txt']); + + // Synchronous iteration (Symbol.iterator) yields [name, Promise]. + const syncPairs = [...zf]; + assert.deepStrictEqual(syncPairs.map(([k]) => k).sort(), ['a.txt', 'b.txt']); + const resolved = await Promise.all(syncPairs.map(([, v]) => v)); + assert.deepStrictEqual(resolved.map((e) => e.name).sort(), ['a.txt', 'b.txt']); + } finally { + await zf[Symbol.asyncDispose](); + } + + const writable = await zlib.ZipFile.open(filePath, { writable: true }); + await assert.rejects(writable.addEntry({}), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => writable.addEntrySync({}), { code: 'ERR_INVALID_ARG_TYPE' }); + await writable.close(); + + const zf2 = zlib.ZipFile.openSync(filePath); + zf2[Symbol.dispose](); + + await fs.unlink(filePath); +}); + +// -- ZipFile (on-disk) error paths --------------------------------------------- + +test('ZipFile.open()/openSync() reject a file with no end-of-central-directory record', async () => { + const filePath = tmpdir.resolve('zip-coverage-garbage.zip'); + await fs.writeFile(filePath, Buffer.from('not a zip file, just garbage bytes')); + try { + await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fs.unlink(filePath); + } +}); + +test('ZipFile get()/getSync()/stream() reject a missing entry name', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))]); + const filePath = tmpdir.resolve('zip-coverage-notfound.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + await assert.rejects(zf.get('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + assert.throws(() => zf.getSync('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + await assert.rejects(zf.stream('missing'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +test('a local file header offset pointing into the central directory is rejected at open', async () => { + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + const tampered = Buffer.from(archive); + // Point the local file header offset at the central directory itself: the + // member's claimed data range then crosses the directory, which the + // open-time overlap check rejects before anything is read. + tampered.writeUInt32LE(centralHeaderStart, centralHeaderStart + 42); + const filePath = tmpdir.resolve('zip-coverage-cd-offset.zip'); + await fs.writeFile(filePath, tampered); + try { + await assert.rejects(zlib.ZipFile.open(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ }); + assert.throws(() => zlib.ZipFile.openSync(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /overlaps/ }); + } finally { + await fs.unlink(filePath); + } +}); + +test('a declared compressed size reaching past the end of the file is rejected', async () => { + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + const tampered = Buffer.from(archive); + tampered.writeUInt32LE(archive.length * 10, centralHeaderStart + 20); // compressedSize + const filePath = tmpdir.resolve('zip-coverage-eof.zip'); + await fs.writeFile(filePath, tampered); + + try { + // Member bounds are validated against the file size up front, so the lie + // is caught at open time - before any buffered read path could allocate + // `compressedSize` bytes for it. + await assert.rejects(zlib.ZipFile.open(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + assert.throws(() => zlib.ZipFile.openSync(filePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + } finally { + await fs.unlink(filePath); + } +}); + +test('a Zip64-declared compressed size of kMaxLength is rejected at open', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + + // A Zip64-declared compressedSize equal to kMaxLength is the largest value + // that still parses (readSafeUint64 caps at the safe-integer ceiling, which + // is kMaxLength on 64-bit), but such a member cannot lie inside this tiny + // file, so the open-time member bounds check rejects the archive before + // the buffering read paths (whose own kMaxLength guard remains as defense + // in depth for 32-bit, where kMaxLength is smaller than real file sizes) + // could ever allocate for it. + const centralHeaderStart = 30 + name.length + content.length; + const nameStart = centralHeaderStart + 46; + const tlv = Buffer.allocUnsafe(4 + 8); + tlv.writeUInt16LE(0x0001, 0); + tlv.writeUInt16LE(8, 2); + tlv.writeBigUInt64LE(BigInt(require('node:buffer').kMaxLength), 4); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, tlv, after]); + patched.writeUInt16LE(tlv.length, centralHeaderStart + 30); + patched.writeUInt32LE(0xffffffff, centralHeaderStart + 20); // compressedSize sentinel + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + tlv.length, eocdOffset + 12); + + const toolargeFilePath = tmpdir.resolve('zip-coverage-toolarge.zip'); + await fs.writeFile(toolargeFilePath, patched); + try { + await assert.rejects(zlib.ZipFile.open(toolargeFilePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + assert.throws(() => zlib.ZipFile.openSync(toolargeFilePath), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); + } finally { + await fs.unlink(toolargeFilePath); + } +}); + +// -- forcing Zip64 structures without a multi-gigabyte archive ----------------- + +test('createZipArchiveSync() also switches to Zip64 structures at 0xFFFF entries', () => { + const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]); + const entries = []; + for (let i = 0; i < 0x10000; i++) { + entries.push(zlib.ZipEntry.createSync(`entry-${i}`, Buffer.alloc(0), { method: 'store' })); + } + const chunks = []; + for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk); + const archive = Buffer.concat(chunks); + assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE)); + assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 0x10000); +}, { timeout: 120_000 }); + +// -- createZipArchive()'s single options argument, baseOffset, and Readable return -- + +const CENTRAL_FILE_HEADER_SIGNATURE = Buffer.from([0x50, 0x4b, 0x01, 0x02]); + +test('createZipArchive() returns a pipeable, async-iterable, non-object-mode Readable', async () => { + const { Readable } = require('node:stream'); + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const stream = zlib.createZipArchive([entry]); + assert.ok(stream instanceof Readable); + assert.strictEqual(stream.readableObjectMode, false); + const chunks = []; + for await (const chunk of stream) chunks.push(chunk); + assert.strictEqual([...zlib.ZipEntry.read(Buffer.concat(chunks))][0].name, 'f.txt'); +}); + +test('createZipArchive()/createZipArchiveSync() take a plain string as comment shorthand', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], 'hello'))); + assert.strictEqual(zip.comment, 'hello'); + + const entrySync = zlib.ZipEntry.createSync('f.txt', Buffer.from('hi')); + const chunks = [...zlib.createZipArchiveSync([entrySync], 'hello-sync')]; + const zipSync = new zlib.ZipBuffer(Buffer.concat(chunks)); + assert.strictEqual(zipSync.comment, 'hello-sync'); +}); + +test('createZipArchive()/createZipArchiveSync() take an { comment, baseOffset } options object', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi')); + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([entry], { comment: 'hi there' }))); + assert.strictEqual(zip.comment, 'hi there'); +}); + +test('createZipArchive()/createZipArchiveSync() reject a non-string, non-object options argument', async () => { + await assert.rejects(drain(zlib.createZipArchive([], 123)), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], 123)], { code: 'ERR_INVALID_ARG_TYPE' }); + await assert.rejects(drain(zlib.createZipArchive([], null)), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('createZipArchive()/createZipArchiveSync() validate options.baseOffset', async () => { + await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: -1 })), { code: 'ERR_OUT_OF_RANGE' }); + await assert.rejects(drain(zlib.createZipArchive([], { baseOffset: 1.5 })), { code: 'ERR_OUT_OF_RANGE' }); + assert.throws(() => [...zlib.createZipArchiveSync([], { baseOffset: -1 })], { code: 'ERR_OUT_OF_RANGE' }); +}); + +test('options.baseOffset shifts every recorded offset, so a prefixed archive is ' + + 'self-describing without relying on prefix auto-detection', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset')); + const prefix = Buffer.from('#!/bin/sh\nexit 0\n'); + + const shifted = await drain(zlib.createZipArchive([entry], { baseOffset: prefix.byteLength })); + const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength); + + const entryUnshifted = await zlib.ZipEntry.create('f.txt', Buffer.from('hello offset')); + const unshifted = await drain(zlib.createZipArchive([entryUnshifted])); + const unshiftedCentral = unshifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(unshifted.readUInt32LE(unshiftedCentral + 42), 0); + + const combined = Buffer.concat([prefix, shifted]); + const zip = new zlib.ZipBuffer(combined); + assert.strictEqual((await zip.get('f.txt').content()).toString(), 'hello offset'); +}); + +test('zipBuffer.toBuffer()/toBufferSync() forward the same string/options-object shorthand', async () => { + const zip = new zlib.ZipBuffer(await drain(zlib.createZipArchive([]))); + await zip.add('f.txt', Buffer.from('hi')); + + assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer('a comment')).comment, 'a comment'); + assert.strictEqual(new zlib.ZipBuffer(await zip.toBuffer({ comment: 'an object comment' })).comment, + 'an object comment'); + assert.strictEqual(new zlib.ZipBuffer(zip.toBufferSync('sync comment')).comment, 'sync comment'); + + const prefix = Buffer.from('junk\n'); + const shifted = await zip.toBuffer({ baseOffset: prefix.byteLength }); + const shiftedCentral = shifted.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(shifted.readUInt32LE(shiftedCentral + 42), prefix.byteLength); +}); + +// -- round-trip fidelity of foreign metadata ---------------------------------- + +test('a non-Unix "version made by" host survives re-serialization', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const entry = await zlib.ZipEntry.create(name, content, { method: 'store' }); + const archive = Buffer.from(await buildArchive([entry])); + + // Rewrite the entry as DOS-made (host byte 0, sec. 4.4.2) with DOS-style + // external attributes (0x20, the archive bit): the high 16 bits are NOT + // Unix permissions for this host, so `mode` must read 0, and the host byte + // must survive re-serialization - stamping it as Unix would turn the + // zeroed high bits into Unix mode 0000 for downstream extractors. + const centralHeaderStart = 30 + name.length + content.length; + archive[centralHeaderStart + 5] = 0; // "version made by" host byte + archive.writeUInt32LE(0x20, centralHeaderStart + 38); // external attributes + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.mode, 0); + assert.strictEqual(read.isSymlink, false); + + const rewritten = await buildArchive([read]); + const rewrittenCentral = rewritten.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.strictEqual(rewritten[rewrittenCentral + 5], 0); // host byte preserved + assert.strictEqual(rewritten.readUInt32LE(rewrittenCentral + 38), 0x20); + const [reread] = zlib.ZipEntry.read(rewritten); + assert.strictEqual(reread.mode, 0); + // The finalized entry itself now answers from its snapshotted metadata, + // which must apply the same made-by gate. + assert.strictEqual(read.mode, 0); + assert.strictEqual(read.isSymlink, false); +}); + +// -- content() ownership ------------------------------------------------------- + +test('content()/contentSync() of a stored in-memory entry return caller-owned memory', async () => { + const archive = await buildArchive( + [await zlib.ZipEntry.create('s.txt', Buffer.from('hello'), { method: 'store' })]); + const zip = new zlib.ZipBuffer(archive); + const entry = zip.get('s.txt'); + + // If content() returned a view into the archive, zeroing it would corrupt + // the entry and the next read would fail CRC-32 verification. + (await entry.content()).fill(0); + assert.deepStrictEqual(await entry.content(), Buffer.from('hello')); + entry.contentSync().fill(0); + assert.deepStrictEqual(entry.contentSync(), Buffer.from('hello')); +}); + +// Injects a raw extra-field blob into a single-entry archive's central +// header without touching any size/offset field (unlike injectRawZip64Extra +// above, which also plants an overflow sentinel). +function injectCentralExtra(archive, name, contentLength, extraBytes) { + const centralHeaderStart = 30 + name.length + contentLength; + const nameStart = centralHeaderStart + 46; + const extraLengthOffset = centralHeaderStart + 30; + assert.strictEqual(archive.readUInt16LE(extraLengthOffset), 0); + const before = archive.subarray(0, nameStart + name.length); + const after = archive.subarray(nameStart + name.length); + const patched = Buffer.concat([before, extraBytes, after]); + patched.writeUInt16LE(extraBytes.length, extraLengthOffset); + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extraBytes.length, eocdOffset + 12); + return patched; +} + +// -- input coercion ------------------------------------------------------------ + +test('a plain Uint8Array is accepted anywhere binary input is, and non-binary input is rejected', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('u.txt', Buffer.from('hi'))]); + const viaU8 = new zlib.ZipBuffer(new Uint8Array(archive)); + assert.strictEqual((await viaU8.get('u.txt').content()).toString(), 'hi'); + const entry = await zlib.ZipEntry.create('v.txt', new Uint8Array([1, 2, 3]), { method: 'store' }); + assert.deepStrictEqual(await entry.content(), Buffer.from([1, 2, 3])); + assert.throws(() => new zlib.ZipBuffer('not binary'), { code: 'ERR_INVALID_ARG_TYPE' }); + await assert.rejects(zlib.ZipEntry.create('w.txt', 'not binary'), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +test('forEach() passes thisArg through on ZipBuffer and ZipFile', async () => { + const archive = await buildArchive([await zlib.ZipEntry.create('t.txt', Buffer.from('x'))]); + const zip = new zlib.ZipBuffer(archive); + const ctx = { hits: 0 }; + zip.forEach(function() { this.hits++; }, ctx); + assert.strictEqual(ctx.hits, 1); + + const filePath = tmpdir.resolve('zip-coverage-foreach.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + const fileCtx = { hits: 0 }; + zf.forEach(function() { this.hits++; }, fileCtx); + zf.forEachSync(function() { this.hits++; }, fileCtx); + assert.strictEqual(fileCtx.hits, 2); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +// -- entry metadata before serialization ---------------------------------------- + +test('freshly created entries expose their metadata before serialization', async () => { + const sym = zlib.ZipEntry.createSymlink('link', 'target'); + assert.strictEqual(sym.mode, 0o777); // The symlink default + assert.strictEqual(sym.isSymlink, true); + assert.strictEqual(sym.isFile, false); + + const entry = await zlib.ZipEntry.create('m.txt', Buffer.from('x'), { + comment: 'note', mode: 0o640, modified: new Date(1700000000000), + }); + assert.deepStrictEqual(entry.nameBuffer, Buffer.from('m.txt')); + assert.strictEqual(entry.comment, 'note'); + assert.strictEqual(entry.modified.getTime(), 1700000000000); + assert.strictEqual(entry.mode, 0o640); + assert.strictEqual(entry.isSymlink, false); +}); + +// -- extra-field edge cases ------------------------------------------------------ + +test('a Unicode Path extra field with an unknown version is ignored', async () => { + const name = 'plain.txt'; + const content = Buffer.from('hi'); + const archive = await buildArchive([await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const utf8Name = Buffer.from('other.txt'); + const up = Buffer.allocUnsafe(4 + 5 + utf8Name.length); + up.writeUInt16LE(0x7075, 0); + up.writeUInt16LE(5 + utf8Name.length, 2); + up.writeUInt8(2, 4); // Unsupported version: only version 1 is defined + up.writeUInt32LE(0, 5); // CRC-32 (irrelevant; the version check comes first) + utf8Name.copy(up, 9); + const patched = injectCentralExtra(archive, name, content.length, up); + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.name, 'plain.txt'); +}); + +test('malformed or empty timestamp extra fields fall back to the DOS time', async () => { + const name = 't.txt'; + const content = Buffer.from('x'); + const modified = new Date(2024, 3, 5, 10, 20, 30); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store', modified })]); + const ntfs = Buffer.allocUnsafe(4 + 12); + ntfs.writeUInt16LE(0x000a, 0); + ntfs.writeUInt16LE(12, 2); + ntfs.writeUInt32LE(0, 4); // Reserved dword + ntfs.writeUInt16LE(1, 8); // Tag 1... + ntfs.writeUInt16LE(200, 10); // ...claiming 200 bytes: overruns its record + ntfs.writeUInt32LE(0, 12); + const ut = Buffer.from([0x55, 0x54, 0x01, 0x00, 0x00]); // "UT", flags 0: no mtime present + const ux = Buffer.allocUnsafe(4 + 4); + ux.writeUInt16LE(0x5855, 0); + ux.writeUInt16LE(4, 2); // Too short for the atime+mtime pair + ux.writeUInt32LE(123, 4); + const patched = injectCentralExtra( + archive, name, content.length, Buffer.concat([ntfs, ut, ux])); + const [read] = zlib.ZipEntry.read(patched); + assert.strictEqual(read.modified.getTime(), modified.getTime()); +}); + +test('a preserved timestamp extra survives re-serialization without duplication', async () => { + const odd = new Date(1700000001000); // An odd second: gets a "UT" extra + const name = 'odd.txt'; + const content = Buffer.from('x'); + const first = await buildArchive( + [await zlib.ZipEntry.create(name, content, { modified: odd, method: 'store' })]); + const [read] = zlib.ZipEntry.read(first); + const second = await buildArchive([read]); + const [reread] = zlib.ZipEntry.read(second); + assert.strictEqual(reread.modified.getTime(), odd.getTime()); + // Exactly one extended-timestamp record: the preserved one, no new copy. + const central = second.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + const nameLength = second.readUInt16LE(central + 28); + const extraLength = second.readUInt16LE(central + 30); + const extra = second.subarray( + central + 46 + nameLength, central + 46 + nameLength + extraLength); + let utRecords = 0; + for (let pos = 0; pos + 4 <= extra.length;) { + if (extra.readUInt16LE(pos) === 0x5455) utRecords++; + pos += 4 + extra.readUInt16LE(pos + 2); + } + assert.strictEqual(utRecords, 1); +}); + +// -- file-backed metadata and I/O failure paths ---------------------------------- + +test('modified falls back to the central directory when the local header is malformed', async () => { + const name = 'm.txt'; + const content = Buffer.from('hello'); + const modified = new Date(2024, 3, 5, 10, 20, 30); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store', modified })])); + archive.writeUInt32LE(0xdeadbeef, 0); // Corrupt the local header signature + const filePath = tmpdir.resolve('zip-coverage-badlocal.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + try { + const entry = await zf.get(name); + // Metadata resolution swallows the local-header failure... + assert.strictEqual(entry.modified.getTime(), modified.getTime()); + // ...but content reads fail loudly on it. + await assert.rejects(entry.content(), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await zf.close(); + await fs.unlink(filePath); + } +}); + +test('a file that shrinks after open is rejected as unexpected EOF when read', async () => { + // The open-time bounds and overlap checks guarantee every member lies + // inside the file *as it was at open time*; a file truncated afterwards + // (the only way a positioned read can now hit EOF) must fail cleanly. + const name = 'a.txt'; + const content = Buffer.from('hello'); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const filePath = tmpdir.resolve('zip-coverage-eof-local.zip'); + await fs.writeFile(filePath, archive); + const zf = await zlib.ZipFile.open(filePath); + const zfSync = zlib.ZipFile.openSync(filePath); + try { + await fs.truncate(filePath, 10); // Cuts into the local file header + await assert.rejects((await zf.get(name)).content(), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ }); + assert.throws(() => zfSync.getSync(name).contentSync(), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /unexpected end of file/ }); + } finally { + await zf.close(); + zfSync.closeSync(); + await fs.unlink(filePath); + } +}); + +test('a failed streaming addEntry() restores the archive and the queue continues', async () => { + const filePath = tmpdir.resolve('zip-coverage-rollback.zip'); + await fs.writeFile(filePath, await buildArchive( + [await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))])); + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + async function* failing() { + yield Buffer.from('partial data that overwrote the central directory'); + throw new Error('source failed'); + } + await assert.rejects( + zip.addEntry(zlib.ZipEntry.createStream('bad.txt', failing())), + /source failed/); + // The rewrite restored the directory in place, and the mutation queue + // keeps accepting work after a failure. + await zip.add('good.txt', Buffer.from('ok')); + } finally { + await zip.close(); + } + const reread = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['good.txt', 'seed.txt']); + assert.strictEqual((await (await reread.get('seed.txt')).content()).toString(), 'seed'); + assert.strictEqual((await (await reread.get('good.txt')).content()).toString(), 'ok'); + } finally { + await reread.close(); + await fs.unlink(filePath); + } +}); + +test('opening a missing archive surfaces the file-system error', async () => { + const missing = tmpdir.resolve('zip-coverage-missing.zip'); + await assert.rejects(zlib.ZipFile.open(missing), { code: 'ENOENT' }); + assert.throws(() => zlib.ZipFile.openSync(missing), { code: 'ENOENT' }); + await assert.rejects( + drain(zlib.zipFiles([[tmpdir.resolve('missing-src.txt'), 'a.txt']], { followSymlinks: false })), + { code: 'ENOENT' }); +}); + +test('using a ZipFile after close() is rejected cleanly', async () => { + const filePath = tmpdir.resolve('zip-coverage-after-close.zip'); + await fs.writeFile(filePath, await buildArchive( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))])); + + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const entry = await zip.get('a.txt'); + await zip.close(); + // A read through a retained entry is rejected by the shared closed-state, + // never by falling through to the (possibly reused) raw descriptor. + await assert.rejects(entry.content(), { code: 'ERR_INVALID_STATE' }); + // Further mutations are rejected the same way... + await assert.rejects( + zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // ...and a second close() is an idempotent no-op, not a double-close. + await zip.close(); + + const zipSync = zlib.ZipFile.openSync(filePath, { writable: true }); + const entrySync = zipSync.getSync('a.txt'); + zipSync.closeSync(); + assert.throws(() => entrySync.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws( + () => zipSync.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + zipSync.closeSync(); + await fs.unlink(filePath); +}); + +// -- writer-side Zip64 fields ---------------------------------------------------- + +test('an entry starting beyond 4 GiB records its offset in a Zip64 extra field', async () => { + const BASE = 0x1_0000_0000; // 4 GiB + const entry = await zlib.ZipEntry.create('far.txt', Buffer.from('hello'), { method: 'store' }); + const archive = await drain(zlib.createZipArchive([entry], { baseOffset: BASE })); + const central = archive.indexOf(CENTRAL_FILE_HEADER_SIGNATURE); + assert.notStrictEqual(central, -1); + assert.strictEqual(archive.readUInt16LE(central + 6), 45); // Version needed: Zip64 + assert.strictEqual(archive.readUInt32LE(central + 42), 0xffffffff); // Offset sentinel + const nameLength = archive.readUInt16LE(central + 28); + const extraStart = central + 46 + nameLength; + assert.strictEqual(archive.readUInt16LE(extraStart), 0x0001); + assert.strictEqual(archive.readUInt16LE(extraStart + 2), 8); + assert.strictEqual(archive.readBigUInt64LE(extraStart + 4), BigInt(BASE)); + // The trailer is promoted to Zip64 too: the directory offset overflows. + assert.notStrictEqual(archive.indexOf(Buffer.from([0x50, 0x4b, 0x06, 0x06])), -1); +}); + +test('extra fields that no longer fit their 16-bit length field are rejected on write', async () => { + const name = 'big-extra.txt'; + const content = Buffer.from('hi'); + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + // Give the entry a preserved (unknown-ID) extra field near the 65,535-byte + // cap; serializing it at an offset beyond 4 GiB must add a 12-byte Zip64 + // record, pushing the total over the cap. + const filler = Buffer.allocUnsafe(4 + 65526); + filler.writeUInt16LE(0x9999, 0); + filler.writeUInt16LE(65526, 2); + filler.fill(0xab, 4); + const patched = injectCentralExtra(archive, name, content.length, filler); + const [read] = zlib.ZipEntry.read(patched); + await assert.rejects( + drain(zlib.createZipArchive([read], { baseOffset: 0x1_0000_0000 })), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE', message: /extra fields/ }); +}); + +// -- archive-end discovery edge cases -------------------------------------------- + +test('a Zip64 EOCD record pushed out of the tail by its data sector is found by re-reading', async () => { + const PAD = 80000; // Larger than the fixed-size tail read + const record = Buffer.alloc(56 + PAD); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(BigInt(44 + PAD), 4); // Remainder size, incl. the sector + record.writeUInt16LE((3 << 8) | 45, 12); + record.writeUInt16LE(45, 14); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(0n, 8); // The record starts at offset 0 + locator.writeUInt32LE(1, 16); + const comment = Buffer.from('sector'); + const eocd = Buffer.alloc(22 + comment.length); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt32LE(0xffffffff, 12); // Size sentinel: the Zip64 record is required + eocd.writeUInt16LE(comment.length, 20); + comment.copy(eocd, 22); + const bytes = Buffer.concat([record, locator, eocd]); + + // In one buffer the record is directly reachable... + assert.strictEqual(new zlib.ZipBuffer(bytes).size, 0); + + // ...but a tail read must notice it starts before the tail and re-read. + const filePath = tmpdir.resolve('zip-coverage-sector.zip'); + await fs.writeFile(filePath, bytes); + const zf = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zf.size, 0); + assert.strictEqual(zf.comment, 'sector'); + } finally { + await zf.close(); + } + const zfSync = zlib.ZipFile.openSync(filePath); + try { + assert.strictEqual(zfSync.size, 0); + } finally { + zfSync.closeSync(); + } + await fs.unlink(filePath); +}); + +test('a locator offset beyond the safe-integer range falls back to classic fields', () => { + const buf = buildMinimalZip64Archive({ locator: { recordOffset: 0xffffffffffffffffn } }); + buf.writeUInt32LE(0xdeadbeef, 0); // Corrupt the record so only the fallback can succeed + assert.strictEqual([...zlib.ZipEntry.read(buf)].length, 0); +}); + +test('ZipFile rejects a central header on another disk', async () => { + const name = 'a.txt'; + const content = Buffer.from('x'); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })])); + const centralHeaderStart = 30 + name.length + content.length; + archive.writeUInt16LE(1, centralHeaderStart + 34); // Disk number + const filePath = tmpdir.resolve('zip-coverage-disk.zip'); + await fs.writeFile(filePath, archive); + await assert.rejects(zlib.ZipFile.open(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + assert.throws(() => zlib.ZipFile.openSync(filePath), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + await fs.unlink(filePath); +}); + +test('zipFiles() keeps a directory mapping name that already ends in a slash', async () => { + const dir = tmpdir.resolve('zip-coverage-dir'); + await fs.mkdir(dir, { recursive: true }); + const archive = await drain(zlib.zipFiles([[dir, 'mapped/']])); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, 'mapped/'); + assert.strictEqual(entry.isDirectory, true); +}); + +test('a streamed entry with the store method round-trips', async () => { + async function* source() { + yield Buffer.from('stored '); + yield Buffer.from('stream'); + } + const entry = zlib.ZipEntry.createStream('s.txt', source(), { method: 'store' }); + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 0); + assert.strictEqual((await read.content()).toString(), 'stored stream'); +}); + +test('createSync() honors an explicit store method', () => { + const entry = zlib.ZipEntry.createSync('s.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + assert.strictEqual(entry.method, 0); + assert.strictEqual(entry.compressed, false); + assert.deepStrictEqual(entry.contentSync(), Buffer.from([1, 2, 3])); +}); diff --git a/test/parallel/test-zlib-zip-edgecases.js b/test/parallel/test-zlib-zip-edgecases.js new file mode 100644 index 000000000000..7d77a07ed6d0 --- /dev/null +++ b/test/parallel/test-zlib-zip-edgecases.js @@ -0,0 +1,188 @@ +'use strict'; + +// Malformed / adversarial / boundary archives, drawn from the ZIP +// parser-differential and torture-test literature (Go archive/zip testdata, +// USENIX'25 semantic-gap paper, PyPI/uv ZIP-confusion advisories, Info-ZIP). +// Node core does not extract to disk, so path-traversal safety is the caller's +// job - these tests pin that our reader surfaces names verbatim and resolves +// every ambiguity from the authoritative central directory. + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_EOCD = 0x06054b50; + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const c of zlib.createZipArchive(entries, comment)) chunks.push(c); + return Buffer.concat(chunks); +} + +// One-entry (stored) archive with independent control of the local vs central +// name, flags, method and sizes - the levers ZIP-confusion attacks pull. +function buildEntryArchive(opts) { + const name = opts.nameBuffer; + const localName = opts.localNameBuffer ?? name; + const content = opts.content ?? Buffer.alloc(0); + const flags = opts.flags ?? 0; + const method = opts.method ?? 0; + const localExtra = opts.localExtra ?? Buffer.alloc(0); + const centralExtra = opts.centralExtra ?? Buffer.alloc(0); + const crc = zlib.crc32(content) >>> 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(SIG_LOCAL, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(flags, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(0x21, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(content.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(localName.length, 26); + local.writeUInt16LE(localExtra.length, 28); + const localRecord = Buffer.concat([local, localName, localExtra, content]); + + const central = Buffer.alloc(46); + central.writeUInt32LE(SIG_CENTRAL, 0); + central.writeUInt16LE(0x0314, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(flags, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(0x21, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(content.length, 20); + central.writeUInt32LE(content.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(centralExtra.length, 30); + central.writeUInt32LE(0, 42); + const centralRecord = Buffer.concat([central, name, centralExtra]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralRecord.length, 12); + eocd.writeUInt32LE(localRecord.length, 16); + return Buffer.concat([localRecord, centralRecord, eocd]); +} + +// -- parser-confusion / semantic-gap defenses -------------------------------- + +test('the central directory is authoritative when the local header disagrees', () => { + // Local header claims "fake.txt"; central directory says "real.txt". + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('real.txt'), + localNameBuffer: Buffer.from('fake.txt'), + content: Buffer.from('payload'), + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, 'real.txt'); + assert.strictEqual(entry.contentSync().toString(), 'payload'); +}); + +test('duplicate central-directory names: read() yields all, get() takes the last', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dup.txt', Buffer.from('first')), + await zlib.ZipEntry.create('dup.txt', Buffer.from('second')), + ]); + assert.deepStrictEqual([...zlib.ZipEntry.read(archive)].map((e) => e.name), ['dup.txt', 'dup.txt']); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 1); + assert.strictEqual(zip.get('dup.txt').contentSync().toString(), 'second'); +}); + +test('path-unsafe names are surfaced verbatim, never normalized or rejected', () => { + for (const name of ['../../etc/passwd', '/etc/passwd', 'a\\b\\c.txt', 'C:\\evil.dll']) { + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: Buffer.from(name) })); + assert.strictEqual(entry.name, name); + } +}); + +// -- structural boundaries ---------------------------------------------------- + +test('data prepended before the archive (SFX stub) is tolerated via prefix detection', async () => { + const inner = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('hello')), + await zlib.ZipEntry.create('b.txt', Buffer.from('world')), + ]); + const prefixed = Buffer.concat([Buffer.alloc(1000, 0x7f), inner]); // stub bytes + using zip = new zlib.ZipBuffer(prefixed); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'hello'); +}); + +test('an empty archive (EOCD only) reads as zero entries', () => { + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + assert.deepStrictEqual([...zlib.ZipEntry.read(eocd)], []); + using zip = new zlib.ZipBuffer(eocd); + assert.strictEqual(zip.size, 0); +}); + +test('directory entries and zero-byte file entries round-trip', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('dir/', Buffer.alloc(0)), + await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)), + ]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.get('dir/').isDirectory, true); + const empty = zip.get('empty.txt'); + assert.strictEqual(empty.isDirectory, false); + assert.strictEqual(empty.contentSync().length, 0); + assert.strictEqual(empty.crc32, 0); +}); + +test('an archive comment at the maximum length (65535 bytes) round-trips', async () => { + const comment = 'z'.repeat(0xffff); + const archive = await buildArchive([await zlib.ZipEntry.create('a.txt', Buffer.from('x'))], comment); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.comment.length, 0xffff); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'x'); +}); + +// -- unsupported features are rejected, not misread -------------------------- + +test('a traditional-encrypted entry (bit 0) is rejected', () => { + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + content: Buffer.from('secret'), + flags: 0x0001, + })); + assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a WinZip-AES entry (method 99, bit 0, 0x9901 extra) is rejected as encrypted', () => { + const aes = Buffer.concat([ + Buffer.from([0x01, 0x99]), // extra id 0x9901 + Buffer.from([0x07, 0x00]), // size 7 + Buffer.from([0x01, 0x00]), // AE-1 version + Buffer.from([0x41, 0x45]), // "AE" + Buffer.from([0x03]), // AES-256 + Buffer.from([0x00, 0x00]), // Actual method (store) + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + content: Buffer.from('cipher'), + flags: 0x0001, + method: 99, + centralExtra: aes, + localExtra: aes, + })); + assert.throws(() => entry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('a Zip64 sentinel size with no Zip64 extra field is rejected', () => { + const archive = buildEntryArchive({ nameBuffer: Buffer.from('f'), content: Buffer.from('hi') }); + // Force the central uncompressed-size field to the sentinel without adding a + // Zip64 extra field, so "look in the Zip64 record" points at nothing. + const cd = archive.readUInt32LE(archive.length - 22 + 16); + archive.writeUInt32LE(0xffffffff, cd + 24); + assert.throws(() => [...zlib.ZipEntry.read(archive)][0].size, + { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); diff --git a/test/parallel/test-zlib-zip-encoding.js b/test/parallel/test-zlib-zip-encoding.js new file mode 100644 index 000000000000..f1091ac1f745 --- /dev/null +++ b/test/parallel/test-zlib-zip-encoding.js @@ -0,0 +1,244 @@ +'use strict'; + +// Reading foreign-encoder edge cases faithfully: non-UTF-8 (CP437) and +// Unicode-Path-extra filenames, raw name bytes, full Unix mode bits and +// symlink type, and modification times carried in extra fields rather than +// the coarse DOS date/time. These synthesize the exact header bytes real +// tools (Windows Explorer, Info-ZIP, 7-Zip, WinRAR) emit, since our own +// writer only ever produces UTF-8 names and DOS timestamps. + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_EOCD = 0x06054b50; + +// Build a minimal one-entry (stored) archive with full control over every +// header field, so tests can reproduce exactly what a foreign encoder writes. +function buildEntryArchive(opts) { + const name = opts.nameBuffer; + const content = opts.content ?? Buffer.alloc(0); + const flags = opts.flags ?? 0; + const localExtra = opts.localExtra ?? Buffer.alloc(0); + const centralExtra = opts.centralExtra ?? Buffer.alloc(0); + const external = (opts.externalAttrs ?? 0) >>> 0; + const versionMadeBy = opts.versionMadeBy ?? 0x0314; // host 3 (Unix), v2.0 + const dosTime = opts.dosTime ?? 0; + const dosDate = opts.dosDate ?? 0x21; // 1980-01-01 + const crc = zlib.crc32(content) >>> 0; + + const local = Buffer.alloc(30); + local.writeUInt32LE(SIG_LOCAL, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(flags, 6); + local.writeUInt16LE(0, 8); // store + local.writeUInt16LE(dosTime, 10); + local.writeUInt16LE(dosDate, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(content.length, 18); + local.writeUInt32LE(content.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(localExtra.length, 28); + const localRecord = Buffer.concat([local, name, localExtra, content]); + + const central = Buffer.alloc(46); + central.writeUInt32LE(SIG_CENTRAL, 0); + central.writeUInt16LE(versionMadeBy, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(flags, 8); + central.writeUInt16LE(0, 10); + central.writeUInt16LE(dosTime, 12); + central.writeUInt16LE(dosDate, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(content.length, 20); + central.writeUInt32LE(content.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(centralExtra.length, 30); + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(external, 38); + central.writeUInt32LE(0, 42); // local header offset + const centralRecord = Buffer.concat([central, name, centralExtra]); + + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + eocd.writeUInt16LE(1, 8); + eocd.writeUInt16LE(1, 10); + eocd.writeUInt32LE(centralRecord.length, 12); + eocd.writeUInt32LE(localRecord.length, 16); + return Buffer.concat([localRecord, centralRecord, eocd]); +} + +// -- filename encoding -------------------------------------------------------- + +test('a bit-11-clear name is decoded as CP437, not mangled as UTF-8', () => { + // 0x81 is "ü" in CP437; as a lone UTF-8 byte it is invalid (would be U+FFFD). + const nameBuffer = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "caf" + ü + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer })); + assert.strictEqual(entry.name, 'cafü'); + assert.deepStrictEqual(entry.nameBuffer, nameBuffer); +}); + +test('a bit-11-set name is decoded as UTF-8, with raw bytes on nameBuffer', () => { + const nameBuffer = Buffer.from('café-名前.txt', 'utf8'); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer, flags: 0x0800 })); + assert.strictEqual(entry.name, 'café-名前.txt'); + assert.deepStrictEqual(entry.nameBuffer, nameBuffer); +}); + +test('a valid Unicode Path extra field (0x7075) overrides the CP437 name', () => { + const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]); // "cafü" in CP437 + const utf8 = Buffer.from('café.txt', 'utf8'); + const up = Buffer.concat([ + Buffer.from([0x75, 0x70]), // id 0x7075 + (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(), + Buffer.from([0x01]), // version 1 + (() => { const b = Buffer.alloc(4); b.writeUInt32LE(zlib.crc32(cp437Name) >>> 0, 0); return b; })(), + utf8, + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up })); + assert.strictEqual(entry.name, 'café.txt'); +}); + +test('a Unicode Path extra field with a stale CRC is ignored (falls back to CP437)', () => { + const cp437Name = Buffer.from([0x63, 0x61, 0x66, 0x81]); + const utf8 = Buffer.from('renamed.txt', 'utf8'); + const up = Buffer.concat([ + Buffer.from([0x75, 0x70]), + (() => { const b = Buffer.alloc(2); b.writeUInt16LE(5 + utf8.length, 0); return b; })(), + Buffer.from([0x01]), + Buffer.from([0xde, 0xad, 0xbe, 0xef]), // wrong CRC of the standard name + utf8, + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ nameBuffer: cp437Name, centralExtra: up })); + assert.strictEqual(entry.name, 'cafü'); // stale extra ignored +}); + +test('ZipBuffer.get() keys by the decoded (CP437) name', () => { + const nameBuffer = Buffer.from([0x81, 0x2e, 0x74, 0x78, 0x74]); // "ü.txt" + using zip = new zlib.ZipBuffer(buildEntryArchive({ nameBuffer, content: Buffer.from('x') })); + assert.strictEqual(zip.has('ü.txt'), true); + assert.strictEqual(zip.get('ü.txt').contentSync().toString(), 'x'); +}); + +// -- Unix mode + symlink ------------------------------------------------------ + +test('setuid/setgid/sticky bits round-trip through a read', async () => { + for (const mode of [0o4755, 0o2750, 0o1777, 0o755, 0o640]) { + const entry = await zlib.ZipEntry.create('f', Buffer.from('x'), { mode }); + const chunks = []; + for await (const c of zlib.createZipArchive([entry])) chunks.push(c); + const [read] = zlib.ZipEntry.read(Buffer.concat(chunks)); + assert.strictEqual(read.mode, mode, `0o${mode.toString(8)} -> 0o${read.mode.toString(8)}`); + } +}); + +test('a symlink entry is reported as a symlink, not a file', () => { + const target = Buffer.from('../target'); + const S_IFLNK = 0o120000; + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('link'), + content: target, + externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0, + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.isSymlink, true); + assert.strictEqual(entry.isFile, false); + assert.strictEqual(entry.isDirectory, false); + assert.strictEqual(entry.mode, 0o777); + assert.strictEqual(entry.contentSync().toString(), '../target'); +}); + +test('external attributes from a non-Unix host expose no mode and no symlink', () => { + const S_IFLNK = 0o120000; + const archive = buildEntryArchive({ + nameBuffer: Buffer.from('x'), + externalAttrs: ((S_IFLNK | 0o777) << 16) >>> 0, // Looks like a symlink... + versionMadeBy: 0x0014, // ...but host 0 (FAT/DOS), so not Unix mode + }); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.mode, 0); + assert.strictEqual(entry.isSymlink, false); +}); + +// -- modification time from extra fields -------------------------------------- + +// 2017-10-31T21:11:57Z, deliberately not representable in the 2-second DOS +// field so an extra-field time can be told apart from the DOS fallback. +const MTIME_SECS = 1509484317; + +function extField(id, body) { + const head = Buffer.alloc(4); + head.writeUInt16LE(id, 0); + head.writeUInt16LE(body.length, 2); + return Buffer.concat([head, body]); +} + +test('the extended-timestamp extra field (0x5455) sets the modification time', () => { + const body = Buffer.alloc(5); + body.writeUInt8(0x01, 0); // mtime present + body.writeInt32LE(MTIME_SECS, 1); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + centralExtra: extField(0x5455, body), + dosDate: 0x4a21, // A different (2017) DOS date, to prove the extra wins + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('the NTFS extra field (0x000a) sets a high-resolution modification time', () => { + const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n; + const times = Buffer.alloc(24); + times.writeBigUInt64LE(ns100, 0); // mtime + times.writeBigUInt64LE(ns100, 8); // atime + times.writeBigUInt64LE(ns100, 16); // ctime + const body = Buffer.concat([ + Buffer.alloc(4), // reserved + extField(0x0001, times), // attribute tag 1 + ]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + localExtra: extField(0x000a, body), // NTFS times live in the local header + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('the Info-ZIP Unix extra field (0x5855) sets the modification time', () => { + const body = Buffer.alloc(8); + body.writeInt32LE(1, 0); // atime + body.writeInt32LE(MTIME_SECS, 4); // mtime + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + centralExtra: extField(0x5855, body), + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('NTFS time is preferred over the extended timestamp when both are present', () => { + const extBody = Buffer.alloc(5); + extBody.writeUInt8(0x01, 0); + extBody.writeInt32LE(MTIME_SECS - 3600, 1); // an hour earlier + const ns100 = (BigInt(MTIME_SECS) + 11644473600n) * 10000000n; + const times = Buffer.alloc(24); + times.writeBigUInt64LE(ns100, 0); + const ntfsBody = Buffer.concat([Buffer.alloc(4), extField(0x0001, times)]); + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + localExtra: Buffer.concat([extField(0x000a, ntfsBody), extField(0x5455, extBody)]), + })); + assert.strictEqual(entry.modified.getTime(), MTIME_SECS * 1000); +}); + +test('with no timestamp extra field the DOS date/time is used', () => { + // DOS date 0x4f21 = year 1980+((0x4f21>>9)&0x7f)=1980+39=2019, month 1, day 1. + const [entry] = zlib.ZipEntry.read(buildEntryArchive({ + nameBuffer: Buffer.from('f'), + dosDate: 0x4f21, + })); + assert.strictEqual(entry.modified.getFullYear(), 2019); +}); diff --git a/test/parallel/test-zlib-zip-experimental-warning.js b/test/parallel/test-zlib-zip-experimental-warning.js new file mode 100644 index 000000000000..021b8360c136 --- /dev/null +++ b/test/parallel/test-zlib-zip-experimental-warning.js @@ -0,0 +1,59 @@ +'use strict'; + +// The zlib ZIP archive API is experimental and must warn when it is *used*, +// but importing or requiring node:zlib - or merely accessing a ZIP export, +// as the ESM loader does when building its named-export bindings - must not +// warn. See lib/zlib.js. + +require('../common'); + +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +const WARNING = /ExperimentalWarning: The zlib ZIP archive API/; + +function run(...args) { + return spawnSync(process.execPath, args, { encoding: 'utf8' }); +} + +test('importing node:zlib (ESM) does not emit the ZIP experimental warning', () => { + const src = "import 'node:zlib';\n" + + "import { ZipFile, ZipBuffer, createZipArchive } from 'node:zlib';\n" + + 'void ZipFile; void ZipBuffer; void createZipArchive;'; + const r = run('--input-type=module', '-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, WARNING); +}); + +test('requiring node:zlib and only reading ZIP exports does not warn', () => { + const src = "const z = require('zlib');\n" + + 'void z.ZipFile; void z.ZipEntry; void z.ZipBuffer; void z.createZipArchive;\n' + + 'void ({} instanceof z.ZipBuffer);'; // The instanceof check must not warn. + const r = run('-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.doesNotMatch(r.stderr, WARNING); +}); + +test('using a ZIP export emits the experimental warning (CJS)', () => { + const r = run('-e', "require('zlib').createZipArchive([]);"); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stderr, WARNING); +}); + +test('using a ZIP export emits the experimental warning (ESM)', () => { + const src = "import { createZipArchive } from 'node:zlib';\ncreateZipArchive([]);"; + const r = run('--input-type=module', '-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stderr, WARNING); +}); + +test('instanceof against the public ZIP classes still matches real instances', () => { + const src = "const z = require('zlib');\n" + + 'const bytes = Buffer.concat([...z.createZipArchiveSync([])]);\n' + + 'const buf = new z.ZipBuffer(bytes);\n' + + 'console.log(buf instanceof z.ZipBuffer, typeof z.ZipFile, z.ZipFile.name);'; + const r = run('-e', src); + assert.strictEqual(r.status, 0, r.stderr); + assert.match(r.stdout, /^true function ZipFile$/m); +}); diff --git a/test/parallel/test-zlib-zip-files.js b/test/parallel/test-zlib-zip-files.js new file mode 100644 index 000000000000..0c4ed6efe88a --- /dev/null +++ b/test/parallel/test-zlib-zip-files.js @@ -0,0 +1,240 @@ +'use strict'; + +// zlib.zipFiles(): build an archive from files on disk, capturing each file's +// mode and modification time, streaming regular-file contents, and either +// following symlinks (default) or storing them as symlink entries. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const tmpdir = require('../common/tmpdir'); +const path = require('node:path'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +async function collect(readable) { + const chunks = []; + for await (const chunk of readable) chunks.push(chunk); + return Buffer.concat(chunks); +} + +// Disposal of a streaming source completes asynchronously - its descriptor is +// closed on the libuv threadpool - and an abandoned archive tears its queued +// sources down one after another, each waiting on the previous close. So the +// moment "every source is destroyed" can trail the destroy() call, arbitrarily +// far on a loaded machine. Wait for that real end state rather than assuming a +// fixed delay has been long enough. +async function waitForAllDestroyed(streams) { + const deadline = Date.now() + common.platformTimeout(5000); + while (streams.some((s) => !s.destroyed) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} + +// Write `count` throwaway files and return streaming entries backed by their +// (eagerly opened) read streams, so a test can observe whether each source is +// destroyed. The worst case for leaks: every descriptor is open before the +// archive starts. +async function streamingEntries(count) { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zip-dispose-')); + const streams = []; + const entries = []; + for (let i = 0; i < count; i++) { + const p = path.join(dir, `f${i}.bin`); + await fsp.writeFile(p, Buffer.alloc(1024 * 1024, i)); + const stream = fs.createReadStream(p); + streams.push(stream); + entries.push(zlib.ZipEntry.createStream(`f${i}.bin`, stream, { method: 'store' })); + } + return { dir, streams, entries }; +} + +async function makeTree() { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zipfiles-')); + await fsp.writeFile(path.join(dir, 'a.txt'), 'alpha'); + await fsp.chmod(path.join(dir, 'a.txt'), 0o640); + await fsp.mkdir(path.join(dir, 'sub')); + await fsp.writeFile(path.join(dir, 'sub', 'b.bin'), Buffer.from([1, 2, 3, 4])); + return dir; +} + +test('zipFiles archives files, directories, contents and Unix mode from disk', async () => { + const dir = await makeTree(); + try { + const files = [ + [path.join(dir, 'a.txt'), 'a.txt'], + [path.join(dir, 'sub'), 'sub'], + [path.join(dir, 'sub', 'b.bin'), 'nested/b.bin'], + ]; + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'nested/b.bin', 'sub/']); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha'); + // Windows has no POSIX permission bits (chmod only toggles read-only, and + // stat reports 0o666), so the exact captured mode is Unix-specific. + if (!common.isWindows) { + assert.strictEqual(zip.get('a.txt').mode, 0o640); + } + assert.strictEqual(zip.get('sub/').isDirectory, true); + assert.deepStrictEqual([...zip.get('nested/b.bin').contentSync()], [1, 2, 3, 4]); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles accepts any iterable of [path, name] (array, Map, Object.entries)', async () => { + const dir = await makeTree(); + try { + const p = path.join(dir, 'a.txt'); + const inputs = [ + [[p, 'a.txt']], // array of pairs + new Map([[p, 'a.txt']]), // Map + Object.entries({ [p]: 'a.txt' }), // Object.entries + ]; + for (const files of inputs) { + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.strictEqual(zip.get('a.txt').contentSync().toString(), 'alpha'); + } + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles preserves a sub-second modification time via an extra field', async () => { + const dir = await makeTree(); + try { + const p = path.join(dir, 'a.txt'); + // 1-second-and-a-half past an epoch second, so the DOS field alone can't + // represent it and the extended-timestamp extra field is what carries it. + const when = new Date(1700000000500); + await fsp.utimes(p, when, when); + using zip = new zlib.ZipBuffer(await collect(zlib.zipFiles([[p, 'a.txt']]))); + assert.strictEqual(zip.get('a.txt').modified.getTime(), 1700000000000); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles follows symlinks by default, or stores them when disabled', { + skip: !common.canCreateSymLink() && 'insufficient privileges to create symlinks', +}, async () => { + const dir = await makeTree(); + try { + await fsp.symlink('a.txt', path.join(dir, 'link')); + const files = [[path.join(dir, 'link'), 'link']]; + + // Default: the link is resolved and archived as the target file. + using followed = new zlib.ZipBuffer(await collect(zlib.zipFiles(files))); + assert.strictEqual(followed.get('link').isSymlink, false); + assert.strictEqual(followed.get('link').isFile, true); + assert.strictEqual(followed.get('link').contentSync().toString(), 'alpha'); + + // followSymlinks: false: the link itself becomes a symlink entry. + using stored = new zlib.ZipBuffer(await collect(zlib.zipFiles(files, { followSymlinks: false }))); + const link = stored.get('link'); + assert.strictEqual(link.isSymlink, true); + assert.strictEqual(link.isFile, false); + assert.strictEqual(link.contentSync().toString(), 'a.txt'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipFiles rejects when a listed path does not exist', async () => { + const dir = await makeTree(); + try { + await assert.rejects(collect(zlib.zipFiles([[path.join(dir, 'missing'), 'x']])), + { code: 'ENOENT' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +// -- entry ownership and disposal ---------------------------------------------- + +test('an abandoned createZipArchive() destroys the sources of entries it never reached', async () => { + const { dir, streams, entries } = await streamingEntries(5); + try { + const archive = zlib.createZipArchive(entries); + archive.on('error', () => {}); + let seen = 0; + for await (const chunk of archive) { + seen += chunk.length; + if (seen > 256 * 1024) break; // Bail while still inside the first member + } + archive.destroy(); + await waitForAllDestroyed(streams); + const open = streams.filter((s) => !s.destroyed); + assert.strictEqual(open.length, 0, `${open.length} source streams left open`); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('a fully consumed archive leaves a valid result and ends every source', async () => { + const { dir, streams, entries } = await streamingEntries(4); + try { + const archive = Buffer.concat(await (async () => { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return chunks; + })()); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 4); + assert.strictEqual((await zip.get('f2.bin').content()).length, 1024 * 1024); + assert.strictEqual(streams.filter((s) => !s.destroyed).length, 0); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('zipEntry disposal destroys a streaming source, sync and async', async () => { + const { dir, streams, entries } = await streamingEntries(2); + try { + entries[0][Symbol.dispose](); + await entries[1][Symbol.asyncDispose](); + await waitForAllDestroyed(streams); + assert.strictEqual(streams[0].destroyed, true); + assert.strictEqual(streams[1].destroyed, true); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('disposing an in-memory or file-backed entry is a harmless no-op', async () => { + const mem = await zlib.ZipEntry.create('m.txt', Buffer.from('keep me')); + mem[Symbol.dispose](); + await mem[Symbol.asyncDispose](); + assert.deepStrictEqual(await mem.content(), Buffer.from('keep me')); + + // A file-backed entry borrows the ZipFile's descriptor; disposing it must + // not close that descriptor. + const dir = await fsp.mkdtemp(path.join(tmpdir.path, 'zlib-zip-dispose-fb-')); + const archivePath = path.join(dir, 'a.zip'); + try { + await fsp.writeFile(archivePath, await collect(zlib.createZipArchive( + [await zlib.ZipEntry.create('a.txt', Buffer.from('hello'))]))); + using zf = await zlib.ZipFile.open(archivePath); + const entry = await zf.get('a.txt'); + entry[Symbol.dispose](); + assert.strictEqual((await entry.content()).toString(), 'hello'); + assert.strictEqual((await (await zf.get('a.txt')).content()).toString(), 'hello'); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); + +test('createZipArchiveSync() throws on a streaming entry and disposes the rest', async () => { + const { dir, streams, entries } = await streamingEntries(3); + try { + assert.throws(() => Array.from(zlib.createZipArchiveSync(entries)), + { code: 'ERR_INVALID_STATE' }); + await waitForAllDestroyed(streams); + assert.strictEqual(streams.filter((s) => !s.destroyed).length, 0); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-fuzz.js b/test/parallel/test-zlib-zip-fuzz.js new file mode 100644 index 000000000000..3b45f7af7dce --- /dev/null +++ b/test/parallel/test-zlib-zip-fuzz.js @@ -0,0 +1,85 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +function mulberry32(seed) { + let state = seed >>> 0; + return function() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +async function buildSeedArchive() { + const entries = [ + await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(30))), + await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }), + await zlib.ZipEntry.create('empty/', Buffer.alloc(0)), + ]; + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, 'seed archive')) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function mutate(random, seed) { + const buf = Buffer.from(seed); + const kind = Math.floor(random() * 4); + if (kind === 0) { + // Flip a handful of random bits. + const flips = 1 + Math.floor(random() * 8); + for (let i = 0; i < flips; i++) { + buf[Math.floor(random() * buf.length)] ^= 1 << Math.floor(random() * 8); + } + } else if (kind === 1) { + // Overwrite a random window with boundary-ish values. + const boundary = [0x00, 0xff, 0x50, 0x4b][Math.floor(random() * 4)]; + const start = Math.floor(random() * buf.length); + const len = Math.min(buf.length - start, 1 + Math.floor(random() * 8)); + buf.fill(boundary, start, start + len); + } else if (kind === 2) { + // Truncate. + return buf.subarray(0, Math.floor(random() * buf.length)); + } else { + // Extend with random padding. + const pad = Buffer.allocUnsafe(1 + Math.floor(random() * 16)); + for (let i = 0; i < pad.length; i++) pad[i] = Math.floor(random() * 256); + return Buffer.concat([buf, pad]); + } + return buf; +} + +test('the parser only ever throws Error on mutated archives, never crashes or hangs', async () => { + const seed = await buildSeedArchive(); + const random = mulberry32(0x5EED1234); + const iterations = 3000; + + for (let i = 0; i < iterations; i++) { + const candidate = mutate(random, seed); + try { + const entries = [...zlib.ZipEntry.read(candidate)]; + for (const entry of entries) { + try { + await entry.content(); + } catch (err) { + assert.ok(err instanceof Error, `content() threw non-Error: ${err}`); + } + } + } catch (err) { + assert.ok(err instanceof Error, `read() threw non-Error: ${err}`); + } + + try { + using zipBuffer = new zlib.ZipBuffer(candidate); + assert.ok(zipBuffer.size >= 0); + } catch (err) { + assert.ok(err instanceof Error, `ZipBuffer threw non-Error: ${err}`); + } + } +}, { timeout: 60_000 }); diff --git a/test/parallel/test-zlib-zip-hardening.js b/test/parallel/test-zlib-zip-hardening.js new file mode 100644 index 000000000000..de2a9efd4918 --- /dev/null +++ b/test/parallel/test-zlib-zip-hardening.js @@ -0,0 +1,376 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function buildEocd({ diskNumber = 0, cdDiskNumber = 0, cdDiskRecords = 0, + totalRecords = 0, cdSize = 0, cdOffset = 0, comment = Buffer.alloc(0) } = {}) { + const buf = Buffer.allocUnsafe(22 + comment.length); + buf.writeUInt32LE(0x06054b50, 0); + buf.writeUInt16LE(diskNumber, 4); + buf.writeUInt16LE(cdDiskNumber, 6); + buf.writeUInt16LE(cdDiskRecords, 8); + buf.writeUInt16LE(totalRecords, 10); + buf.writeUInt32LE(cdSize, 12); + buf.writeUInt32LE(cdOffset, 16); + buf.writeUInt16LE(comment.length, 20); + comment.copy(buf, 22); + return buf; +} + +test('an empty or tiny buffer is rejected as an invalid archive', () => { + for (const buf of [Buffer.alloc(0), Buffer.alloc(10), Buffer.from('not a zip')]) { + assert.throws(() => [...zlib.ZipEntry.read(buf)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } +}); + +test('garbage or truncated data is rejected', () => { + const garbage = Buffer.alloc(100, 0x41); + assert.throws(() => [...zlib.ZipEntry.read(garbage)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + const truncated = buildEocd({ totalRecords: 5, cdSize: 46 * 5 }).subarray(0, 10); + assert.throws(() => [...zlib.ZipEntry.read(truncated)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); + +test('an EOCD-looking signature inside a trailing comment is not mistaken for the real one', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + // The comment scan walks backward through the trailing comment bytes + // before it reaches the genuine EOCD signature; embedding 4 bytes that + // look like one partway through must not be mistaken for the real record. + const fakeSignature = String.fromCharCode(0x50, 0x4b, 0x05, 0x06); + const archive = await buildArchive([entry], `before ${fakeSignature} after`); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 1); + assert.strictEqual(read[0].name, 'f.txt'); +}); + +test('a declared-size mismatch is rejected as corrupt', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + + // Shrink the *declared* uncompressed size in the central directory record + // without touching the stored bytes themselves, so the amount of data + // produced no longer matches what the header promised. + const tampered = Buffer.from(archive); + const centralHeaderStart = 30 + 'f.txt'.length + 'hello world'.length; + const uncompressedSizeOffset = centralHeaderStart + 24; + tampered.writeUInt32LE(1, uncompressedSizeOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.strictEqual(tamperedEntry.size, 1); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); +}); + +test('CRC-32 verification catches a single flipped byte, and can be disabled', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const contentStart = 30 + 'f.txt'.length; + tampered[contentStart] ^= 0xff; + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + const unverified = await tamperedEntry.content({ verify: false }); + assert.strictEqual(unverified.length, 'hello world'.length); +}); + +test('content() enforces maxSize before allocating', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(entry.content({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); +}); + +test('a forged small header whose content inflates past its declared size is rejected', async () => { + // The up-front maxSize check trusts the declared size, so a bomb forges a + // tiny declared size to clear it; the decompressor's maxOutputLength + // backstop is capped at declared + 1 bytes, so the lie is caught (as + // corruption - the declared size is provably wrong) without ever + // materializing more than the declared size, no matter how large maxSize + // is. + for (const { method, re } of [ + { method: 'deflate', re: /inflates beyond its declared size/ }, + { method: 'zstd', re: /decompresses beyond its declared size/ }, + ]) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'.repeat(5000)), { method }); + const archive = await buildArchive([entry]); + const tampered = Buffer.from(archive); + const eocd = tampered.length - 22; // No comment, so EOCD is the last 22 bytes + const cdOffset = tampered.readUInt32LE(eocd + 16); + tampered.writeUInt32LE(50, cdOffset + 24); // Forge declared uncompressedSize + + const [e] = zlib.ZipEntry.read(tampered); + assert.strictEqual(e.size, 50); // 50 <= maxSize 100 clears the up-front check + await assert.rejects(e.content({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re }); + assert.throws(() => e.contentSync({ maxSize: 100 }), { code: 'ERR_ZIP_ENTRY_CORRUPT', message: re }); + } +}); + +test('getMaxZipContentSize()/setMaxZipContentSize() control the default guard', async () => { + const original = zlib.getMaxZipContentSize(); + try { + zlib.setMaxZipContentSize(1); + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hello world')); + await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } finally { + zlib.setMaxZipContentSize(original); + } + assert.strictEqual(zlib.getMaxZipContentSize(), original); +}); + +test('streaming a partially-consumed entry does not hang or leak', async () => { + async function* source() { + for (let i = 0; i < 1000; i++) { + yield Buffer.alloc(1024, i & 0xff); + } + } + const entry = zlib.ZipEntry.createStream('big.bin', source()); + let count = 0; + let bytesSeen = 0; + for await (const chunk of entry) { + count++; + bytesSeen += chunk.length; + if (count > 2) break; + } + assert.ok(count > 2); + assert.ok(bytesSeen > 0); +}); + +test('an overlong file name is rejected', async () => { + await assert.rejects( + zlib.ZipEntry.create('x'.repeat(70000), Buffer.alloc(0)), + { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }, + ); +}); + +test('an empty file name is rejected', async () => { + await assert.rejects( + zlib.ZipEntry.create('', Buffer.alloc(0)), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('a multi-disk archive is rejected', () => { + const eocd = buildEocd({ diskNumber: 1, cdDiskNumber: 1 }); + assert.throws(() => [...zlib.ZipEntry.read(eocd)], { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('an encrypted entry is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('secret'), { method: 'store' }); + const archive = await buildArchive([entry]); + // Set the encrypted bit (bit 0) in both the local and central header flags. + const tampered = Buffer.from(archive); + const localFlagsOffset = 6; + tampered.writeUInt16LE(tampered.readUInt16LE(localFlagsOffset) | 0x0001, localFlagsOffset); + const centralHeaderStart = 30 + 'f.txt'.length + 'secret'.length; + const centralFlagsOffset = centralHeaderStart + 8; + tampered.writeUInt16LE(tampered.readUInt16LE(centralFlagsOffset) | 0x0001, centralFlagsOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +test('an entry using an unsupported compression method is rejected', async () => { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' }); + const archive = await buildArchive([entry]); + // Set the method to 1 (Shrunk), which this implementation does not support, + // in both the local and central headers. + const tampered = Buffer.from(archive); + const localMethodOffset = 8; + tampered.writeUInt16LE(1, localMethodOffset); + const centralHeaderStart = 30 + 'f.txt'.length + 'hi'.length; + const centralMethodOffset = centralHeaderStart + 10; + tampered.writeUInt16LE(1, centralMethodOffset); + + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.strictEqual(tamperedEntry.method, 1); + await assert.rejects(tamperedEntry.content(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); + assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_UNSUPPORTED_FEATURE' }); +}); + +// -- shapes borrowed from CPython's zipfile test corpus (synthesized bytes) ------ + +test('every possible truncation of an archive is rejected, deterministically', async () => { + // CPython's test_damaged_zipfile: any prefix of a valid archive must fail + // cleanly (never succeed, never throw a non-Error). The fuzz test samples + // truncation points randomly; this pins all of them for a small archive. + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hello world'), { method: 'store' })]); + for (let n = 0; n < archive.length; n++) { + assert.throws(() => [...zlib.ZipEntry.read(archive.subarray(0, n))], + { code: /^ERR_ZIP_/ }, `truncation at ${n} bytes`); + } +}); + +test('trailing padding after the EOCD is tolerated', async () => { + // Some streaming writers pad their output to a block size; CPython + // tolerates trailing newlines/NULs and so does the pass-2 EOCD scan. + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })]); + const padded = Buffer.concat([archive, Buffer.from('\r\n\0\0\0')]); + const [entry] = zlib.ZipEntry.read(padded); + assert.strictEqual(entry.name, 'f.txt'); + assert.strictEqual((await entry.content()).toString(), 'hi'); +}); + +test('junk appended past a declared comment is tolerated and the comment preserved', async () => { + const archive = await buildArchive( + [await zlib.ZipEntry.create('f.txt', Buffer.from('hi'), { method: 'store' })], + 'this is a comment'); + const appended = Buffer.concat([archive, Buffer.from('abcdef\r\n')]); + const zip = new zlib.ZipBuffer(appended); + assert.strictEqual(zip.comment, 'this is a comment'); + assert.strictEqual(zip.get('f.txt').contentSync().toString(), 'hi'); +}); + +test('an EOCD comment length overrunning the end of the file is rejected', async () => { + // CPython's _EndRecData silently returns a truncated comment here; this + // implementation deliberately rejects the candidate instead (both scan + // passes require the declared comment to fit), so the archive has no + // recognizable EOCD at all. + const comment = 'padding-padding-padding'; + const archive = Buffer.from(await buildArchive([], comment)); + const eocdOffset = archive.length - 22 - comment.length; + assert.strictEqual(archive.readUInt32LE(eocdOffset), 0x06054b50); + archive.writeUInt16LE(comment.length + 10, eocdOffset + 20); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /no end of central directory/ }); +}); + +test('a central directory that cannot fit before the EOCD is rejected', () => { + // CPython: "negative central directory offset". The declared size/offset + // put the directory past the EOCD, so the prefix computation goes + // negative. + const eocd = buildEocd({ cdDiskRecords: 1, totalRecords: 1, cdSize: 1, cdOffset: 0xffffff }); + assert.throws(() => [...zlib.ZipEntry.read(eocd)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /does not fit/ }); +}); + +test('a record count inconsistent with the directory size is rejected', () => { + // 100 records cannot fit in 46 bytes of central directory. Filler bytes + // ahead of the EOCD stand in for that region so the directory nominally + // fits inside the archive and the count check is what fires. + const eocd = buildEocd({ cdDiskRecords: 100, totalRecords: 100, cdSize: 46 }); + const archive = Buffer.concat([Buffer.alloc(46), eocd]); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /inconsistent/ }); +}); + +test('a corrupted or overrunning central directory header is rejected', async () => { + const nameA = 'a.txt'; + const nameB = 'b.txt'; + const content = Buffer.from('x'); + const two = await buildArchive([ + await zlib.ZipEntry.create(nameA, content, { method: 'store' }), + await zlib.ZipEntry.create(nameB, content, { method: 'store' }), + ]); + const memberSize = 30 + nameA.length + content.length; + const centralStart = 2 * memberSize; + const centralRecord = 46 + nameA.length; + + // Zero the second central record's signature ("bad magic number"). + const badMagic = Buffer.from(two); + badMagic.writeUInt32LE(0, centralStart + centralRecord); + assert.throws(() => [...zlib.ZipEntry.read(badMagic)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /signature is invalid/ }); + + // Grow the first central record's name length so it overruns the declared + // central directory size ("truncated central directory"). + const overrun = Buffer.from(two); + overrun.writeUInt16LE(2000, centralStart + 28); + assert.throws(() => [...zlib.ZipEntry.read(overrun)], { code: 'ERR_ZIP_INVALID_ARCHIVE' }); +}); + +test('short or overrunning extra-field records do not make an entry unreadable', async () => { + // CPython's test_zipfile_with_short_extra_field shape: advisory extra + // metadata that is malformed is skipped, never fatal. + const name = 'f.txt'; + const content = Buffer.from('hi'); + const base = await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })]); + const centralHeaderStart = 30 + name.length + content.length; + for (const extra of [ + Buffer.from([0x99]), // Shorter than a TLV header + Buffer.from([0x99, 0x99, 0xff]), // Still shorter than a TLV header + Buffer.from([0x99, 0x99, 0xff, 0xff]), // Declared length overruns wildly + ]) { + const before = base.subarray(0, centralHeaderStart + 46 + name.length); + const after = base.subarray(centralHeaderStart + 46 + name.length); + const patched = Buffer.concat([before, extra, after]); + patched.writeUInt16LE(extra.length, centralHeaderStart + 30); + const eocdOffset = patched.length - 22; + patched.writeUInt32LE(patched.readUInt32LE(eocdOffset + 12) + extra.length, eocdOffset + 12); + const [entry] = zlib.ZipEntry.read(patched); + assert.strictEqual(entry.name, name); + assert.strictEqual((await entry.content()).toString(), 'hi'); + } +}); + +test('two central records quoting the same data are rejected as a possible zip bomb', async () => { + // The "quoted overlap" bomb shape (CVE-2024-0450 in other + // implementations): N central records pointing at one region turn a tiny + // file into N full-size extractions. + const nameA = 'a.txt'; + const nameB = 'b.txt'; + const content = Buffer.from('x'); + const two = Buffer.from(await buildArchive([ + await zlib.ZipEntry.create(nameA, content, { method: 'store' }), + await zlib.ZipEntry.create(nameB, content, { method: 'store' }), + ])); + const memberSize = 30 + nameA.length + content.length; + const centralStart = 2 * memberSize; + const centralRecord = 46 + nameA.length; + // Point the second record's local header at the first member's. + two.writeUInt32LE(0, centralStart + centralRecord + 42); + assert.throws(() => [...zlib.ZipEntry.read(two)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ }); +}); + +test('a member whose data crosses into the central directory is rejected', async () => { + const name = 'f.txt'; + const content = Buffer.from('hello'); + const archive = Buffer.from(await buildArchive( + [await zlib.ZipEntry.create(name, content, { method: 'store' })])); + const centralHeaderStart = 30 + name.length + content.length; + // Lie about the compressed size so the member's data range reaches into + // the central directory (while staying inside the buffer). + archive.writeUInt32LE(content.length + 40, centralHeaderStart + 20); + assert.throws(() => [...zlib.ZipEntry.read(archive)], + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /possible zip bomb/ }); +}); + +test('a Zip64 EOCD record size-field lie is tolerated when the locator is correct', () => { + // The record's own size field is only consulted by the backward recovery + // scan; when the locator points straight at a valid record, a lying size + // field (CPython rejects 0 or 100 here) does not matter. + const record = Buffer.alloc(56); + record.writeUInt32LE(0x06064b50, 0); + record.writeBigUInt64LE(100n, 4); // Lie: the remainder is 44 bytes + record.writeUInt16LE((3 << 8) | 45, 12); + record.writeUInt16LE(45, 14); + const locator = Buffer.alloc(20); + locator.writeUInt32LE(0x07064b50, 0); + locator.writeBigUInt64LE(0n, 8); + locator.writeUInt32LE(1, 16); + const eocd = buildEocd({ cdSize: 0xffffffff }); + const zip = new zlib.ZipBuffer(Buffer.concat([record, locator, eocd])); + assert.strictEqual(zip.size, 0); +}); + +test('a NUL byte inside an entry name is preserved verbatim', async () => { + // CPython truncates the name at the NUL on read; this implementation + // surfaces names verbatim and leaves interpretation to the caller. + const name = 'foo\x00bar.txt'; + const archive = await buildArchive( + [await zlib.ZipEntry.create(name, Buffer.from('x'), { method: 'store' })]); + const [entry] = zlib.ZipEntry.read(archive); + assert.strictEqual(entry.name, name); +}); diff --git a/test/parallel/test-zlib-zip-internals.js b/test/parallel/test-zlib-zip-internals.js new file mode 100644 index 000000000000..78316ec37024 --- /dev/null +++ b/test/parallel/test-zlib-zip-internals.js @@ -0,0 +1,53 @@ +// Flags: --expose-internals +'use strict'; + +require('../common'); + +// Directly exercises defense-in-depth guards in lib/internal/zip/ that the +// public API cannot reach on this platform - they exist for 32-bit limits, +// or for hypothetical callers that skip the pre-validation every current +// call site performs - so their behavior stays pinned down. + +const assert = require('node:assert'); +const { test } = require('node:test'); +const zlib = require('node:zlib'); + +const { readSafeUint64 } = require('internal/zip/binary'); +const { parseZip64Extra } = require('internal/zip/extra-fields'); +const { LocalFileHeader } = require('internal/zip/headers'); +const { decodeMemberSync } = require('internal/zip/compression'); + +test('readSafeUint64 rejects a field extending past the buffer', () => { + // Every current caller validates the containing range first; the guard + // protects future call sites. + assert.throws(() => readSafeUint64(Buffer.alloc(7), 0), + { code: 'ERR_ZIP_INVALID_ARCHIVE', message: /out of bounds/ }); +}); + +test('parseZip64Extra returns nothing when no field is wanted', () => { + // Reachable only if a caller asks with no overflow sentinel present; + // CentralFileHeader always wants at least one field when it calls. + assert.deepStrictEqual(parseZip64Extra(Buffer.alloc(0), {}), {}); +}); + +test('LocalFileHeader.length reports 0 when the fixed part does not fit', () => { + // The only current caller passes exactly 30 bytes, so the short-buffer + // branch cannot trigger through it. + assert.strictEqual(LocalFileHeader.length(Buffer.alloc(10), 0), 0); +}); + +test('decodeMemberSync bounds output by the declared size without a caller limit', () => { + // The public wrappers always pass maxSize (defaulted); without one, the + // declared-size cap alone must stop an overrun. + const data = Buffer.alloc(4096, 0x61); + const compressed = zlib.deflateRawSync(data); + const info = { + name: 'lie.txt', + flags: 0, + method: 8, + crc32: zlib.crc32(data), + uncompressedSize: 16, // Lie: it inflates to 4096 bytes + }; + assert.throws(() => decodeMemberSync(compressed, info), + { code: 'ERR_ZIP_ENTRY_CORRUPT', message: /beyond its declared size/ }); +}); diff --git a/test/parallel/test-zlib-zip-interop.js b/test/parallel/test-zlib-zip-interop.js new file mode 100644 index 000000000000..429f7b02973e --- /dev/null +++ b/test/parallel/test-zlib-zip-interop.js @@ -0,0 +1,132 @@ +'use strict'; + +require('../common'); +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +function hasTool(command, args = ['--help']) { + try { + const result = spawnSync(command, args, { stdio: 'ignore' }); + // Require a clean exit, not merely a successful spawn: Windows ships an + // App Execution Alias stub for `python3` that runs, prints "Python was + // not found", and exits non-zero (9009) - `error` is undefined for it, + // so a spawn-only check would wrongly report the tool as present. + return result.error === undefined && result.status === 0; + } catch { + return false; + } +} + +// Rendering a UTF-8 entry name (general-purpose bit 11, which we set) through +// Info-ZIP unzip/zipinfo needs two things that are independent of whether the +// archive is well-formed: (1) the build must be compiled with UNICODE_SUPPORT +// - macOS's bundled Info-ZIP is not, and mangles such names no matter what - +// and (2) at runtime the name is converted to the process locale, which drops +// or garbles non-ASCII bytes under the "C"/"POSIX" locale. We detect (1) and +// pick an installed UTF-8 locale for (2); when unzip lacks UNICODE_SUPPORT the +// non-ASCII name assertion is skipped (its round-trip is still covered by +// bsdtar/libarchive, which handles UTF-8 correctly regardless). +function findUtf8Locale() { + const result = spawnSync('locale', ['-a'], { encoding: 'utf8' }); + if (result.status !== 0 || !result.stdout) return null; + const locales = result.stdout.split('\n').map((line) => line.trim()); + const preferred = ['C.UTF-8', 'C.utf8', 'en_US.UTF-8', 'en_US.utf8']; + for (const name of preferred) { + if (locales.includes(name)) return name; + } + return locales.find((name) => /utf-?8$/i.test(name)) || null; +} + +test('an archive written by createZipArchive is readable by unzip, zipinfo, and bsdtar', async (t) => { + if (!hasTool('unzip', ['-v']) || !hasTool('zipinfo', ['-v']) || !hasTool('bsdtar', ['--version'])) { + t.skip('unzip, zipinfo, or bsdtar is not available'); + return; + } + const locale = findUtf8Locale(); + if (!locale) { + t.skip('no UTF-8 locale is available to render non-ASCII entry names'); + return; + } + const env = { ...process.env, LANG: locale, LC_ALL: locale }; + // Only assert non-ASCII names through zipinfo when this Info-ZIP build can + // actually render them (see the note above). + const unzipBuild = spawnSync('unzip', ['-v'], { encoding: 'utf8' }).stdout || ''; + const zipinfoUnicode = /UNICODE_SUPPORT/.test(unzipBuild); + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-')); + try { + const files = { + 'hello.txt': Buffer.from('Hello, world!'.repeat(50)), + 'raw.bin': Buffer.from([1, 2, 3, 4, 5]), + 'unicode-名前.txt': Buffer.from('unicode name'), + }; + const entries = []; + for (const [name, data] of Object.entries(files)) { + entries.push(await zlib.ZipEntry.create(name, data)); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + const archivePath = path.join(dir, 'archive.zip'); + await fs.writeFile(archivePath, Buffer.concat(chunks)); + + const unzipTest = spawnSync('unzip', ['-t', archivePath], { env }); + assert.strictEqual(unzipTest.status, 0, unzipTest.stderr?.toString()); + + const zipinfo = spawnSync('zipinfo', [archivePath], { env }); + assert.strictEqual(zipinfo.status, 0); + for (const name of Object.keys(files)) { + if (!zipinfoUnicode && /[^\x20-\x7e]/.test(name)) continue; + assert.ok(zipinfo.stdout.toString().includes(name), `zipinfo missing ${name}`); + } + + const extractDir = path.join(dir, 'out'); + await fs.mkdir(extractDir); + const bsdtar = spawnSync('bsdtar', ['-xf', archivePath, '-C', extractDir], { env }); + assert.strictEqual(bsdtar.status, 0, bsdtar.stderr?.toString()); + for (const [name, data] of Object.entries(files)) { + const extracted = await fs.readFile(path.join(extractDir, name)); + assert.deepStrictEqual(extracted, data); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +test('an archive written by Python\'s zipfile module is readable by ZipFile', async (t) => { + if (!hasTool('python3', ['--version'])) { + t.skip('python3 is not available'); + return; + } + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-interop-')); + try { + const archivePath = path.join(dir, 'python.zip'); + const script = ` +import zipfile +with zipfile.ZipFile(${JSON.stringify(archivePath)}, 'w') as z: + z.writestr('a.txt', 'hello from python') + z.writestr('b.bin', bytes(range(256))) +`; + const result = spawnSync('python3', ['-c', script]); + assert.strictEqual(result.status, 0, result.stderr?.toString()); + + const zip = await zlib.ZipFile.open(archivePath); + try { + const a = await zip.get('a.txt'); + assert.strictEqual((await a.content()).toString(), 'hello from python'); + const b = await zip.get('b.bin'); + assert.deepStrictEqual(await b.content(), Buffer.from(Array.from({ length: 256 }, (_, i) => i))); + } finally { + await zip.close(); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-metadata.js b/test/parallel/test-zlib-zip-metadata.js new file mode 100644 index 000000000000..760b28414552 --- /dev/null +++ b/test/parallel/test-zlib-zip-metadata.js @@ -0,0 +1,96 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function roundTrip(options) { + const entry = await zlib.ZipEntry.create('f.txt', Buffer.from('x'), options); + const chunks = []; + for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk); + const archive = Buffer.concat(chunks); + return [...zlib.ZipEntry.read(archive)][0]; +} + +test('modification time round-trips at 2-second resolution', async () => { + const modified = new Date(2024, 5, 15, 13, 45, 30); + const read = await roundTrip({ modified }); + assert.strictEqual(read.modified.getFullYear(), 2024); + assert.strictEqual(read.modified.getMonth(), 5); + assert.strictEqual(read.modified.getDate(), 15); + assert.strictEqual(read.modified.getHours(), 13); + assert.strictEqual(read.modified.getMinutes(), 45); + assert.strictEqual(read.modified.getSeconds(), 30); +}); + +test('a sub-second modification time is preserved to the second via an extra field', async () => { + // The DOS field is 2-second, local; a sub-second time additionally writes an + // extended-timestamp extra field, so it round-trips to the exact UTC second. + const read = await roundTrip({ modified: new Date(1700000000500) }); + assert.strictEqual(read.modified.getTime(), 1700000000000); +}); + +test('an odd whole-second modification time is preserved via an extra field', async () => { + // The DOS fields have 2-second resolution, so an odd second is just as + // unrepresentable as a sub-second part and also gets the extended-timestamp + // extra field. + const read = await roundTrip({ modified: new Date(1700000001000) }); + assert.strictEqual(read.modified.getTime(), 1700000001000); +}); + +test('a date before 1980 is clamped to the DOS epoch', async () => { + const read = await roundTrip({ modified: new Date(1970, 0, 1) }); + assert.strictEqual(read.modified.getFullYear(), 1980); + assert.strictEqual(read.modified.getMonth(), 0); + assert.strictEqual(read.modified.getDate(), 1); +}); + +test('a date after 2107 is clamped to the DOS ceiling', async () => { + const read = await roundTrip({ modified: new Date(2200, 0, 1) }); + assert.strictEqual(read.modified.getFullYear(), 2107); + assert.strictEqual(read.modified.getMonth(), 11); + assert.strictEqual(read.modified.getDate(), 31); +}); + +test('Unix mode round-trips for files and directories', async () => { + const file = await roundTrip({ mode: 0o600 }); + assert.strictEqual(file.mode, 0o600); + + const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0), { mode: 0o700 }); + const chunks = []; + for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk); + const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0]; + assert.strictEqual(read.mode, 0o700); + assert.strictEqual(read.isDirectory, true); +}); + +test('default modes are applied when none is given', async () => { + const file = await roundTrip({}); + assert.strictEqual(file.mode, 0o644); + + const dirEntry = await zlib.ZipEntry.create('dir/', Buffer.alloc(0)); + const chunks = []; + for await (const chunk of zlib.createZipArchive([dirEntry])) chunks.push(chunk); + const read = [...zlib.ZipEntry.read(Buffer.concat(chunks))][0]; + assert.strictEqual(read.mode, 0o755); +}); + +test('an entry comment round-trips', async () => { + const read = await roundTrip({ comment: 'a comment' }); + assert.strictEqual(read.comment, 'a comment'); +}); + +test('zipEntry.compressed reports compressed storage for any method', async () => { + const big = Buffer.from('x'.repeat(1000)); // Compressible, so deflate/zstd win + const deflated = await zlib.ZipEntry.create('d.txt', big, { method: 'deflate' }); + const stored = await zlib.ZipEntry.create('s.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + const zstd = await zlib.ZipEntry.create('z.txt', big, { method: 'zstd' }); + assert.strictEqual(deflated.compressed, true); + assert.strictEqual(stored.compressed, false); + assert.strictEqual(zstd.compressed, true); + + // Survives a read-back round-trip too. + assert.strictEqual((await roundTrip({ method: 'store' })).compressed, false); +}); diff --git a/test/parallel/test-zlib-zip-property.js b/test/parallel/test-zlib-zip-property.js new file mode 100644 index 000000000000..0f7cf8586fd2 --- /dev/null +++ b/test/parallel/test-zlib-zip-property.js @@ -0,0 +1,130 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +// A small, seeded PRNG so failures are reproducible without pulling in a +// dependency. +function mulberry32(seed) { + let state = seed >>> 0; + return function() { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomBuffer(random, length) { + const buf = Buffer.allocUnsafe(length); + for (let i = 0; i < length; i++) buf[i] = Math.floor(random() * 256); + return buf; +} + +function randomName(random, index) { + const unicodeBits = ['a', 'é', '日', '🙂', 'z']; + const segment = unicodeBits[Math.floor(random() * unicodeBits.length)]; + return `dir-${index}/${segment}-${index}.bin`; +} + +async function buildTree(random, count) { + const specs = []; + for (let i = 0; i < count; i++) { + const size = Math.floor(random() * 4096); + specs.push({ + name: randomName(random, i), + data: randomBuffer(random, size), + method: random() < 0.5 ? 'deflate' : 'store', + mode: random() < 0.5 ? 0o644 : 0o600, + modified: new Date(2000 + Math.floor(random() * 40), Math.floor(random() * 12), + 1 + Math.floor(random() * 27)), + }); + } + const entries = []; + for (const spec of specs) { + entries.push(await zlib.ZipEntry.create(spec.name, spec.data, { + method: spec.method, mode: spec.mode, modified: spec.modified, + })); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return { archive: Buffer.concat(chunks), specs }; +} + +test('random archives round-trip through ZipEntry.read, ZipBuffer, and ZipFile', async () => { + const random = mulberry32(0xC0FFEE); + const rounds = 8; + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-property-')); + try { + for (let round = 0; round < rounds; round++) { + const { archive, specs } = await buildTree(random, 6); + const byName = new Map(specs.map((spec) => [spec.name, spec])); + + // Path 1: ZipEntry.read + for (const entry of zlib.ZipEntry.read(archive)) { + const spec = byName.get(entry.name); + assert.ok(spec, `unexpected entry ${entry.name}`); + assert.deepStrictEqual(await entry.content(), spec.data); + assert.strictEqual(entry.mode, spec.mode); + } + + // Path 2: ZipBuffer + using zipBuffer = new zlib.ZipBuffer(archive); + assert.strictEqual(zipBuffer.size, specs.length); + for (const [name, entry] of zipBuffer) { + const spec = byName.get(name); + assert.deepStrictEqual(await entry.content(), spec.data); + } + + // Path 3: ZipFile (disk-backed), including one streamed read. + const filePath = path.join(dir, `round-${round}.zip`); + await fs.writeFile(filePath, archive); + const zipFile = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zipFile.size, specs.length); + for (const spec of specs) { + const entry = await zipFile.get(spec.name); + assert.deepStrictEqual(await entry.content(), spec.data); + } + const firstName = specs[0].name; + const streamed = []; + for await (const chunk of await zipFile.stream(firstName)) streamed.push(chunk); + assert.deepStrictEqual(Buffer.concat(streamed), specs[0].data); + } finally { + await zipFile.close(); + } + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 60_000 }); + +test('a streamed write round-trips through a streamed read', async () => { + const random = mulberry32(0xBADF00D); + const data = randomBuffer(random, 256 * 1024); + async function* source() { + for (let i = 0; i < data.length; i += 4096) { + yield data.subarray(i, Math.min(i + 4096, data.length)); + } + } + const entry = zlib.ZipEntry.createStream('streamed.bin', source()); + const chunks = []; + for await (const chunk of zlib.createZipArchive([entry])) chunks.push(chunk); + const archive = Buffer.concat(chunks); + + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.name, 'streamed.bin'); + assert.strictEqual(read.size, data.length); + const streamedOut = []; + for await (const chunk of read.contentIterator()) streamedOut.push(chunk); + assert.deepStrictEqual(Buffer.concat(streamedOut), data); +}); diff --git a/test/parallel/test-zlib-zip-security.js b/test/parallel/test-zlib-zip-security.js new file mode 100644 index 000000000000..ba63af08fb2d --- /dev/null +++ b/test/parallel/test-zlib-zip-security.js @@ -0,0 +1,265 @@ +'use strict'; + +// Regression tests for the security review of the ZIP archive API. Each test +// encodes the *desired* safe behaviour; on the pre-fix code it fails, exposing +// the issue. See the review for context. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +let seq = 0; +async function tempZip(entries) { + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + const filePath = path.join(dir, 'archive.zip'); + await fsp.writeFile(filePath, Buffer.concat(chunks)); + return { dir, filePath, cleanup: () => fsp.rm(dir, { recursive: true, force: true }) }; +} + +// -- Finding 1: no closed-state guard; operations after close() fall through +// to a raw (possibly reused) file descriptor. ------------------------------- + +test('post-close operations are rejected cleanly, not via a raw fd', async () => { + const { dir, filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const retained = await zip.get('a.txt'); + await zip.close(); + + // Encourage descriptor reuse: open an unrelated "victim" file right after + // close(), which typically reclaims the just-freed fd number. + const victimPath = path.join(dir, 'victim.txt'); + const VICTIM = 'do-not-touch-this-victim-file'; + await fsp.writeFile(victimPath, VICTIM); + const victimFd = fs.openSync(victimPath, 'r+'); + try { + // A retained entry must not read through the (reused) descriptor. + await assert.rejects(retained.content(), { code: 'ERR_INVALID_STATE' }); + // A post-close mutation must not truncate/overwrite whatever now owns + // the fd number. + await assert.rejects( + zip.addEntry(await zlib.ZipEntry.create('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // A second close() must be a safe no-op, not a double-close of a reused fd. + await zip.close(); + // The victim file must be byte-for-byte intact. + assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM); + } finally { + fs.closeSync(victimFd); + } + } finally { + await cleanup(); + } +}); + +test('post-close operations are rejected cleanly (sync)', async () => { + const { dir, filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('secret-archive-data'))]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + const retained = zip.getSync('a.txt'); + zip.closeSync(); + + const victimPath = path.join(dir, 'victim.txt'); + const VICTIM = 'do-not-touch-this-victim-file'; + fs.writeFileSync(victimPath, VICTIM); + const victimFd = fs.openSync(victimPath, 'r+'); + try { + assert.throws(() => retained.contentSync(), { code: 'ERR_INVALID_STATE' }); + assert.throws( + () => zip.addEntrySync(zlib.ZipEntry.createSync('b.txt', Buffer.from('x'))), + { code: 'ERR_INVALID_STATE' }); + // A second closeSync() must be a safe no-op, not EBADF on a reused fd. + zip.closeSync(); + assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), VICTIM); + } finally { + fs.closeSync(victimFd); + } + } finally { + await cleanup(); + } +}); + +// -- Finding 2: add() awaits ZipEntry.create() before registering the mutation, +// so a synchronous close() can slip in and close the fd underneath it. ------- + +test('closeSync() while an async add() is outstanding is rejected', async () => { + const { filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + // add() runs synchronously up to `await ZipEntry.create(...)` and then + // suspends, having registered nothing yet. + const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 7)); + // The mutation must already be reserved, so a synchronous method sees the + // archive as busy instead of racing the descriptor. + assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' }); + await adding; + await zip.close(); + + const check = await zlib.ZipFile.open(filePath); + try { + assert.ok(check.has('b.txt'), 'the added entry must survive'); + } finally { + await check.close(); + } + } finally { + await cleanup(); + } +}); + +test('an earlier add() completes before a later close()', async () => { + const { filePath, cleanup } = await tempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('data'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + const adding = zip.add('b.txt', Buffer.alloc(1 << 16, 9)); + const closing = zip.close(); + // add() was issued first; it must land before close() tears down the fd. + await Promise.all([adding, closing]); + + const check = await zlib.ZipFile.open(filePath); + try { + assert.ok(check.has('b.txt'), 'the earlier add() must not be lost'); + } finally { + await check.close(); + } + } finally { + await cleanup(); + } +}); + +// -- Finding 3 (root cause): zipFiles() classifies via lstat() and then +// re-opens the path with createReadStream(), so it neither validates the +// opened descriptor's type nor pins it against a swap. A non-regular file +// therefore slips through as an ordinary stream source. -------------------- + +test('zipFiles() rejects a non-regular special file', { + skip: common.isWindows ? 'no /dev/null semantics on Windows' : false, +}, async () => { + const chunks = []; + await assert.rejects( + (async () => { + for await (const chunk of zlib.zipFiles([['/dev/null', 'null']])) chunks.push(chunk); + })(), + (err) => err?.code !== undefined && err.code !== 'ERR_ASSERTION', + 'archiving a character device should be rejected, not stored as an empty file'); +}); + +// -- Concern B (verdict: by design): the default decompression ceiling guards +// the buffering path (content()) against a huge allocation; streaming +// (contentIterator / ZipFile.stream) is deliberately not bound by it, so a +// legitimately large member can be read chunk by chunk. Output is still capped +// per chunk at the declared size, and a caller wanting a cap passes maxSize. +// This locks in that asymmetry (a default ceiling on streaming would break +// multi-gigabyte reads - see test/pummel/test-zlib-zip-slow.js). ------------- + +test('the default ceiling bounds content() but not streaming', async () => { + const chunks = []; + for await (const c of zlib.createZipArchive( + [await zlib.ZipEntry.create('big.txt', Buffer.alloc(4096, 1), { method: 'store' })])) { + chunks.push(c); + } + using zip = new zlib.ZipBuffer(Buffer.concat(chunks)); + const entry = zip.get('big.txt'); + + const saved = zlib.getMaxZipContentSize(); + zlib.setMaxZipContentSize(1024); // Below the entry's 4096 declared bytes. + try { + // One-shot buffering enforces the default ceiling... + await assert.rejects(entry.content(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + // ...but streaming (the bounded-memory path) is not capped by it. + let total = 0; + for await (const chunk of entry.contentIterator()) total += chunk.length; + assert.strictEqual(total, 4096); + // An explicit maxSize still caps streaming when the caller wants it. + await assert.rejects((async () => { + let n = 0; + for await (const chunk of entry.contentIterator({ maxSize: 1024 })) n += chunk.length; + return n; + })(), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + } finally { + zlib.setMaxZipContentSize(saved); + } +}); + +// -- Concern C: when a Zip64 end record is present its values silently override +// the classic EOCD without checking they agree. A non-sentinel classic field +// that disagrees with Zip64 is a parser differential (a classic-only tool and +// Node see different archives) and must be rejected. -------------------------- + +test('open() rejects contradictory classic-EOCD vs Zip64 metadata', async () => { + // 0x10000 entries forces Zip64; the classic total-records field becomes the + // 0xFFFF overflow sentinel. + const entries = []; + for (let i = 0; i < 0x10000; i++) { + entries.push(zlib.ZipEntry.createSync(`e${i}`, Buffer.alloc(0), { method: 'store' })); + } + const bytes = Buffer.concat([...zlib.createZipArchiveSync(entries)]); + + const eocd = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + assert.ok(eocd >= 0, 'classic EOCD present'); + // The classic total-records field must be the 0xFFFF overflow sentinel. + assert.strictEqual(bytes.readUInt16LE(eocd + 10), 0xFFFF); + // Rewrite the sentinel to a smaller, non-sentinel value that disagrees with + // the Zip64 record (which says 0x10000). + const tampered = Buffer.from(bytes); + tampered.writeUInt16LE(3, eocd + 10); + + assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const p = path.join(dir, 'contradiction.zip'); + try { + await fsp.writeFile(p, tampered); + await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 120_000 }); + +// -- Finding 4: the file-backed open-time overlap check uses a 30-byte lower +// bound for each local header, while the in-memory reader uses the exact +// local-header length. A crafted "quoted overlap" archive therefore passes +// ZipFile.open() but is (correctly) rejected by ZipBuffer. ----------------- + +test('ZipFile.open() enforces the same member-overlap check as ZipBuffer', async () => { + const chunks = []; + for await (const chunk of zlib.createZipArchive([ + await zlib.ZipEntry.create('A', Buffer.alloc(40, 1), { method: 'store' }), + await zlib.ZipEntry.create('B', Buffer.alloc(40, 2), { method: 'store' }), + ])) chunks.push(chunk); + const tampered = Buffer.concat(chunks); + // Enlarge entry A's *local* extra-field length. Its real data range (which + // the read path locates via the local header) now runs past entry B's local + // header - the quoted/overlapping zip-bomb shape. The central directory is + // left untouched, so the offset+30+compressedSize open check still sees room. + tampered.writeUInt16LE(tampered.readUInt16LE(28) + 8, 28); + + // The in-memory reader rejects it outright. + assert.throws(() => new zlib.ZipBuffer(tampered), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + + // The file-backed reader must reject it too, at open time - not silently + // accept an archive whose members overlap. + const dir = await fsp.mkdtemp(path.join(tmpdir.path, `zip-sec-${seq++}-`)); + const p = path.join(dir, 'overlap.zip'); + try { + await fsp.writeFile(p, tampered); + await assert.rejects(zlib.ZipFile.open(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + assert.throws(() => zlib.ZipFile.openSync(p), { code: 'ERR_ZIP_INVALID_ARCHIVE' }); + } finally { + await fsp.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/test/parallel/test-zlib-zip-sync.js b/test/parallel/test-zlib-zip-sync.js new file mode 100644 index 000000000000..1f7099849b9a --- /dev/null +++ b/test/parallel/test-zlib-zip-sync.js @@ -0,0 +1,247 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +function buildArchiveSync(entries, comment) { + const chunks = []; + for (const chunk of zlib.createZipArchiveSync(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +function createTempZipSync(entries, comment) { + const dir = fs.mkdtempSync(path.join(tmpdir.path, 'zlib-zip-sync-')); + const filePath = path.join(dir, 'archive.zip'); + fs.writeFileSync(filePath, buildArchiveSync(entries, comment)); + return { filePath, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }; +} + +// -- ZipEntry ----------------------------------------------------------------- + +test('ZipEntry.createSync()/contentSync() round-trip, deflate and store', () => { + const deflateEntry = zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000))); + assert.strictEqual(deflateEntry.method, 8); + assert.strictEqual(deflateEntry.contentSync().toString(), 'a'.repeat(1000)); + + const storeEntry = zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }); + assert.strictEqual(storeEntry.method, 0); + assert.deepStrictEqual(storeEntry.contentSync(), Buffer.from([1, 2, 3])); + + // Matches the crc32/size that the async ZipEntry.create() would produce. + const data = Buffer.from('some content to compare'); + assert.strictEqual(zlib.ZipEntry.createSync('x', data).crc32, zlib.crc32(data)); +}); + +test('ZipEntry.createSync()/contentSync() round-trip, zstd', () => { + const zstdEntry = zlib.ZipEntry.createSync('z.txt', Buffer.from('z'.repeat(1000)), { method: 'zstd' }); + assert.strictEqual(zstdEntry.method, 93); + assert.strictEqual(zstdEntry.contentSync().toString(), 'z'.repeat(1000)); +}); + +test('ZipEntry.contentSync() enforces maxSize and CRC verification like content()', () => { + const entry = zlib.ZipEntry.createSync('a.txt', Buffer.from('hello world'), { method: 'store' }); + assert.throws(() => entry.contentSync({ maxSize: 1 }), { code: 'ERR_ZIP_ENTRY_TOO_LARGE' }); + + const archive = buildArchiveSync([entry]); + const tampered = Buffer.from(archive); + tampered[30 + 'a.txt'.length] ^= 0xff; // Flip a content byte. + const [tamperedEntry] = zlib.ZipEntry.read(tampered); + assert.throws(() => tamperedEntry.contentSync(), { code: 'ERR_ZIP_ENTRY_CORRUPT' }); + assert.strictEqual(tamperedEntry.contentSync({ verify: false }).length, 'hello world'.length); +}); + +test('createZipArchiveSync() throws for a streaming (pending) entry', () => { + const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})()); + assert.throws(() => [...zlib.createZipArchiveSync([streaming])], { code: 'ERR_INVALID_STATE' }); +}); + +// -- ZipBuffer ---------------------------------------------------------------- + +test('ZipBuffer.addSync()/toBufferSync() round-trip', () => { + const archive = buildArchiveSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))], 'a comment'); + using zip = new zlib.ZipBuffer(archive); + const added = zip.addSync('b.txt', Buffer.from('b')); + assert.strictEqual(added.name, 'b.txt'); + assert.strictEqual(zip.size, 2); + + const rebuilt = zip.toBufferSync(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.txt']); + assert.strictEqual(reread.get('a.txt').contentSync().toString(), 'a'); + assert.strictEqual(reread.get('b.txt').contentSync().toString(), 'b'); + assert.strictEqual(reread.comment, 'a comment'); +}); + +// -- ZipFile ------------------------------------------------------------------ + +test('ZipFile.openSync() reads the same entries as open()', async () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('a')), + zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath); + try { + assert.strictEqual(zip.writable, false); + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a'); + assert.deepStrictEqual(zip.getSync('b.bin').contentSync(), Buffer.from([1, 2, 3])); + assert.deepStrictEqual([...zip.valuesSync()].map((e) => e.name).sort(), ['a.txt', 'b.bin']); + assert.deepStrictEqual([...zip.entriesSync()].map(([n]) => n).sort(), ['a.txt', 'b.bin']); + const seen = []; + zip.forEachSync((entry, name) => seen.push(name)); + assert.deepStrictEqual(seen.sort(), ['a.txt', 'b.bin']); + } finally { + zip.closeSync(); + } + + const asyncZip = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...asyncZip.keys()].sort(), ['a.txt', 'b.bin']); + } finally { + await asyncZip.close(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile opened via openSync supports `using` (Symbol.dispose)', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + { + using zip = zlib.ZipFile.openSync(filePath); + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'a'); + } + // Disposed: the fd should be closed. Re-opening the same path must still work. + const reopened = zlib.ZipFile.openSync(filePath); + reopened.closeSync(); + } finally { + cleanup(); + } +}); + +test('ZipFile.addEntrySync()/addSync()/deleteSync() alter the file synchronously', () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('AAAA'.repeat(20))), + zlib.ZipEntry.createSync('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + assert.strictEqual(zip.writable, true); + const sizeBefore = fs.statSync(filePath).size; + const added = zip.addSync('c.txt', Buffer.from('a fresh member')); + assert.strictEqual(added.name, 'c.txt'); + assert.strictEqual(zip.size, 3); + assert.ok(fs.statSync(filePath).size > sizeBefore); + assert.strictEqual(zip.getSync('c.txt').contentSync().toString(), 'a fresh member'); + // Original entries untouched. + assert.strictEqual(zip.getSync('a.txt').contentSync().toString(), 'AAAA'.repeat(20)); + + const entry = zlib.ZipEntry.createSync('d.txt', Buffer.from('d')); + assert.strictEqual(zip.addEntrySync(entry), entry); + + const sizeBeforeDelete = fs.statSync(filePath).size; + assert.strictEqual(zip.deleteSync('b.bin'), true); + assert.ok(fs.statSync(filePath).size < sizeBeforeDelete); + assert.strictEqual(zip.deleteSync('does-not-exist'), false); + } finally { + zip.closeSync(); + } + + const reread = zlib.ZipFile.openSync(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt', 'd.txt']); + } finally { + reread.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile.addEntrySync() rejects a pending streaming entry', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + const streaming = zlib.ZipEntry.createStream('big.bin', (async function* () {})()); + assert.throws(() => zip.addEntrySync(streaming), { code: 'ERR_INVALID_STATE' }); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile sync mutators throw ERR_ZIP_NOT_WRITABLE when not opened writable', () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('a.txt', Buffer.from('a'))]); + try { + const zip = zlib.ZipFile.openSync(filePath); + try { + assert.throws(() => zip.addSync('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' }); + assert.throws(() => zip.deleteSync('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' }); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('ZipFile.compactSync() matches compact() and does not touch the open file', () => { + const { filePath, cleanup } = createTempZipSync([ + zlib.ZipEntry.createSync('a.txt', Buffer.from('a'.repeat(1000))), + zlib.ZipEntry.createSync('b.txt', Buffer.from('b'.repeat(1000))), + ]); + try { + const zip = zlib.ZipFile.openSync(filePath, { writable: true }); + try { + zip.deleteSync('b.txt'); + const sizeWithDeadSpace = fs.statSync(filePath).size; + const compacted = zip.compactSync(); + assert.strictEqual(fs.statSync(filePath).size, sizeWithDeadSpace); + assert.ok(compacted.length < sizeWithDeadSpace); + using reread = new zlib.ZipBuffer(compacted); + assert.deepStrictEqual([...reread.keys()], ['a.txt']); + } finally { + zip.closeSync(); + } + } finally { + cleanup(); + } +}); + +test('a sync ZipFile method throws while an async mutation has not settled yet', async () => { + const { filePath, cleanup } = createTempZipSync([zlib.ZipEntry.createSync('seed.txt', Buffer.from('seed'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + // addEntry() (unlike add()) increments the busy counter synchronously, + // at call time - before any internal awaiting - so the checks below + // are guaranteed to observe the archive as busy. + const entry = zlib.ZipEntry.createSync('slow.txt', Buffer.from('x')); + const pending = zip.addEntry(entry); + assert.throws(() => zip.getSync('seed.txt'), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => zip.addSync('y.txt', Buffer.from('y')), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => zip.closeSync(), { code: 'ERR_INVALID_STATE' }); + await pending; + // Once settled, sync methods work again. + assert.strictEqual(zip.getSync('seed.txt').contentSync().toString(), 'seed'); + } finally { + await zip.close(); + } + } finally { + cleanup(); + } +}); diff --git a/test/parallel/test-zlib-zip-writable.js b/test/parallel/test-zlib-zip-writable.js new file mode 100644 index 000000000000..9e036f62de31 --- /dev/null +++ b/test/parallel/test-zlib-zip-writable.js @@ -0,0 +1,347 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +async function createTempZip(entries, comment) { + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-writable-')); + const filePath = path.join(dir, 'archive.zip'); + await fs.writeFile(filePath, await buildArchive(entries, comment)); + return { filePath, cleanup: () => fs.rm(dir, { recursive: true, force: true }) }; +} + +// -- ZipBuffer -------------------------------------------------------------- + +test('ZipBuffer is always writable', async () => { + const archive = await buildArchive([]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.writable, true); +}); + +test('ZipBuffer preserves the archive comment across toBuffer()', async () => { + const archive = await buildArchive([], 'original comment'); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.comment, 'original comment'); + const rebuilt = await zip.toBuffer(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.strictEqual(reread.comment, 'original comment'); +}); + +test('ZipBuffer add()/addEntry()/delete()/clear() mutate the in-memory index', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + ]); + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 1); + + const added = await zip.add('b.txt', Buffer.from('b')); + assert.strictEqual(added.name, 'b.txt'); + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.has('b.txt'), true); + + const entry = await zlib.ZipEntry.create('c.txt', Buffer.from('c')); + assert.strictEqual(zip.addEntry(entry), entry); + assert.strictEqual(zip.size, 3); + + assert.strictEqual(zip.delete('a.txt'), true); + assert.strictEqual(zip.delete('a.txt'), false); + assert.strictEqual(zip.size, 2); + + zip.clear(); + assert.strictEqual(zip.size, 0); +}); + +test('ZipBuffer.toBuffer() serializes the current live set, replacing on overwrite', async () => { + const archive = await buildArchive([ + await zlib.ZipEntry.create('a.txt', Buffer.from('original')), + await zlib.ZipEntry.create('b.txt', Buffer.from('keep')), + ]); + using zip = new zlib.ZipBuffer(archive); + await zip.add('a.txt', Buffer.from('replaced')); + zip.delete('b.txt'); + await zip.add('c.txt', Buffer.from('new')); + + const rebuilt = await zip.toBuffer(); + using reread = new zlib.ZipBuffer(rebuilt); + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'c.txt']); + assert.strictEqual((await reread.get('a.txt').content()).toString(), 'replaced'); + assert.strictEqual((await reread.get('c.txt').content()).toString(), 'new'); +}); + +test('ZipBuffer.addEntry() rejects a non-ZipEntry value', async () => { + const archive = await buildArchive([]); + using zip = new zlib.ZipBuffer(archive); + assert.throws(() => zip.addEntry({ name: 'fake.txt' }), { code: 'ERR_INVALID_ARG_TYPE' }); +}); + +// -- ZipFile ------------------------------------------------------------------ + +test('ZipFile defaults to read-only and rejects mutation', async () => { + const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('a.txt', Buffer.from('a'))]); + try { + const zip = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(zip.writable, false); + await assert.rejects(zip.add('b.txt', Buffer.from('b')), { code: 'ERR_ZIP_NOT_WRITABLE' }); + await assert.rejects(zip.delete('a.txt'), { code: 'ERR_ZIP_NOT_WRITABLE' }); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile opened writable: addEntry() appends where the old CD used to be', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('AAAA'.repeat(20))), + await zlib.ZipEntry.create('b.bin', Buffer.from([1, 2, 3]), { method: 'store' }), + ]); + try { + const sizeBefore = (await fs.stat(filePath)).size; + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + assert.strictEqual(zip.writable, true); + const added = await zip.add('c.txt', Buffer.from('a fresh member')); + assert.strictEqual(added.name, 'c.txt'); + assert.strictEqual(zip.size, 3); + + // The file must actually be altered by the time the call returns. + const sizeAfter = (await fs.stat(filePath)).size; + assert.ok(sizeAfter > sizeBefore); + + // Original entries must be untouched. + assert.strictEqual((await (await zip.get('a.txt')).content()).toString(), 'AAAA'.repeat(20)); + assert.deepStrictEqual(await (await zip.get('b.bin')).content(), Buffer.from([1, 2, 3])); + assert.strictEqual((await (await zip.get('c.txt')).content()).toString(), 'a fresh member'); + } finally { + await zip.close(); + } + + // Re-opening from scratch must see the same three entries. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.deepStrictEqual([...reread.keys()].sort(), ['a.txt', 'b.bin', 'c.txt']); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile addEntry() promotes a spent streaming entry into a readable file-backed entry', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const payload = 'streamed payload'.repeat(64); + async function* source() { + yield Buffer.from(payload.slice(0, 10)); + yield Buffer.from(payload.slice(10)); + } + const streamEntry = zlib.ZipEntry.createStream('s.txt', source()); + + // Before it is written, a streaming entry has no readable content. + await assert.rejects(streamEntry.content(), { code: 'ERR_INVALID_STATE' }); + + const returned = await zip.addEntry(streamEntry); + // addEntry() returns the same object, now promoted in place. + assert.strictEqual(returned, streamEntry); + + // The once-spent entry is now readable, both buffered and streamed. + assert.strictEqual((await streamEntry.content()).toString(), payload); + const chunks = []; + for await (const chunk of streamEntry.contentIterator()) chunks.push(chunk); + assert.strictEqual(Buffer.concat(chunks).toString(), payload); + + // And re-serializable: it can be copied into a fresh archive. + const copy = new zlib.ZipBuffer(await buildArchive([streamEntry])); + assert.strictEqual(copy.get('s.txt').contentSync().toString(), payload); + assert.strictEqual(copy.get('s.txt').crc32 >>> 0, streamEntry.crc32 >>> 0); + + // An in-memory entry added alongside keeps its own buffer (not promoted). + const mem = await zlib.ZipEntry.create('m.txt', Buffer.from('in memory')); + await zip.addEntry(mem); + assert.deepStrictEqual(mem.rawContent, mem.rawContent); // still a Buffer + assert.notStrictEqual(mem.rawContent, null); + } finally { + await zip.close(); + } + + // The bytes persisted correctly and re-open sees the streamed member. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual( + (await (await reread.get('s.txt')).content()).toString(), + 'streamed payload'.repeat(64)); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('an in-place rewrite keeps central and local data-descriptor flags in agreement', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('seed.txt', Buffer.from('seed')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + async function* source() { yield Buffer.from('streamed payload'); } + await zip.addEntry(zlib.ZipEntry.createStream('s.txt', source())); + // A second mutation rebuilds the central directory from the freshly + // adopted headers. The streamed entry's on-disk local header (bit 3 + // set, with a data descriptor after its content) is never rewritten, + // so the rebuilt central header must keep advertising bit 3 - a + // cleared flag would contradict the local header (sec. 4.3.12). + await zip.delete('seed.txt'); + } finally { + await zip.close(); + } + + const raw = await fs.readFile(filePath); + const FLAG_DATA_DESCRIPTOR = 0x0008; + // Only s.txt is live: its central record locates its local header. + const centralPos = raw.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02])); + assert.notStrictEqual(centralPos, -1); + const centralFlags = raw.readUInt16LE(centralPos + 8); + const localOffset = raw.readUInt32LE(centralPos + 42); + assert.strictEqual(raw.readUInt32LE(localOffset), 0x04034b50); + const localFlags = raw.readUInt16LE(localOffset + 6); + assert.strictEqual(localFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR); + assert.strictEqual(centralFlags & FLAG_DATA_DESCRIPTOR, FLAG_DATA_DESCRIPTOR); + + // The archive stays fully readable after the rewrite. + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual( + (await (await reread.get('s.txt')).content()).toString(), 'streamed payload'); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile opened writable: delete() rewrites the CD without growing the file', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + await zlib.ZipEntry.create('b.txt', Buffer.from('b')), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const sizeBefore = (await fs.stat(filePath)).size; + assert.strictEqual(await zip.delete('b.txt'), true); + const sizeAfter = (await fs.stat(filePath)).size; + assert.ok(sizeAfter < sizeBefore); + assert.strictEqual(zip.has('b.txt'), false); + assert.strictEqual(zip.size, 1); + assert.strictEqual(await zip.delete('does-not-exist'), false); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile.compact() streams a fresh archive with no dead entries and does not touch the open file', async () => { + const { filePath, cleanup } = await createTempZip([ + await zlib.ZipEntry.create('a.txt', Buffer.from('a'.repeat(1000))), + await zlib.ZipEntry.create('b.txt', Buffer.from('b'.repeat(1000))), + ]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + await zip.delete('b.txt'); // Leaves dead space behind + const sizeWithDeadSpace = (await fs.stat(filePath)).size; + + const chunks = []; + for await (const chunk of zip.compact()) chunks.push(chunk); + const compacted = Buffer.concat(chunks); + + // compact() must not have modified the still-open file. + assert.strictEqual((await fs.stat(filePath)).size, sizeWithDeadSpace); + + assert.ok(compacted.length < sizeWithDeadSpace); + using reread = new zlib.ZipBuffer(compacted); + assert.deepStrictEqual([...reread.keys()], ['a.txt']); + assert.strictEqual((await reread.get('a.txt').content()).toString(), 'a'.repeat(1000)); + } finally { + await zip.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile preserves the archive comment across writes', async () => { + const { filePath, cleanup } = await createTempZip( + [await zlib.ZipEntry.create('a.txt', Buffer.from('a'))], 'a comment'); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + assert.strictEqual(zip.comment, 'a comment'); + await zip.add('b.txt', Buffer.from('b')); + assert.strictEqual(zip.comment, 'a comment'); + } finally { + await zip.close(); + } + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(reread.comment, 'a comment'); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}); + +test('ZipFile serializes concurrent add()/delete() calls instead of racing', async () => { + const { filePath, cleanup } = await createTempZip([await zlib.ZipEntry.create('seed.txt', Buffer.from('seed'))]); + try { + const zip = await zlib.ZipFile.open(filePath, { writable: true }); + try { + const names = []; + for (let i = 0; i < 20; i++) names.push(`f${i}.txt`); + await Promise.all(names.map((name) => zip.add(name, Buffer.from(name)))); + assert.strictEqual(zip.size, names.length + 1); + for (const name of names) { + assert.strictEqual((await (await zip.get(name)).content()).toString(), name); + } + } finally { + await zip.close(); + } + + const reread = await zlib.ZipFile.open(filePath); + try { + assert.strictEqual(reread.size, 21); + } finally { + await reread.close(); + } + } finally { + await cleanup(); + } +}, { timeout: 30_000 }); diff --git a/test/parallel/test-zlib-zip-zip64.js b/test/parallel/test-zlib-zip-zip64.js new file mode 100644 index 000000000000..d22b14b99baa --- /dev/null +++ b/test/parallel/test-zlib-zip-zip64.js @@ -0,0 +1,38 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +const ZIP64_EOCD_SIGNATURE = Buffer.from([0x50, 0x4b, 0x06, 0x06]); + +async function buildArchive(count) { + const entries = []; + for (let i = 0; i < count; i++) { + entries.push(await zlib.ZipEntry.create(`entry-${i}`, Buffer.alloc(0), { method: 'store' })); + } + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries)) chunks.push(chunk); + return Buffer.concat(chunks); +} + +test('an entry count at or above 0xFFFF forces Zip64 structures', async () => { + const archive = await buildArchive(0x10000); + assert.ok(archive.includes(ZIP64_EOCD_SIGNATURE)); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 0x10000); + + using zip = new zlib.ZipBuffer(archive); + assert.strictEqual(zip.size, 0x10000); + assert.strictEqual(zip.has('entry-0'), true); + assert.strictEqual(zip.has(`entry-${0x10000 - 1}`), true); +}, { timeout: 120_000 }); + +test('an archive below the Zip64 thresholds contains no Zip64 structures', async () => { + const archive = await buildArchive(10); + assert.ok(!archive.includes(ZIP64_EOCD_SIGNATURE)); + assert.strictEqual([...zlib.ZipEntry.read(archive)].length, 10); +}); diff --git a/test/parallel/test-zlib-zip.js b/test/parallel/test-zlib-zip.js new file mode 100644 index 000000000000..910c17122d8f --- /dev/null +++ b/test/parallel/test-zlib-zip.js @@ -0,0 +1,147 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const { test } = require('node:test'); + +async function buildArchive(entries, comment) { + const chunks = []; + for await (const chunk of zlib.createZipArchive(entries, comment)) { + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +// Deterministic high-entropy bytes that deflate/zstd cannot shrink, without +// pulling in node:crypto (unavailable on --without-ssl builds). A xorshift32 +// PRNG is more than random enough to defeat compression. +function incompressibleBytes(length) { + const out = Buffer.allocUnsafe(length); + let state = 0x9e3779b9; + for (let i = 0; i < length; i++) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + out[i] = state & 0xff; + } + return out; +} + +test('round-trips a small archive through ZipEntry.read', async () => { + const entries = [ + await zlib.ZipEntry.create('hello.txt', Buffer.from('Hello, world!'.repeat(20))), + await zlib.ZipEntry.create('raw.bin', Buffer.from([1, 2, 3, 4, 5]), { method: 'store' }), + await zlib.ZipEntry.create('empty.txt', Buffer.alloc(0)), + await zlib.ZipEntry.create('dir/', Buffer.alloc(0)), + ]; + const archive = await buildArchive(entries, 'test comment'); + + const read = [...zlib.ZipEntry.read(archive)]; + assert.strictEqual(read.length, 4); + + const byName = new Map(read.map((entry) => [entry.name, entry])); + assert.strictEqual((await byName.get('hello.txt').content()).toString(), + 'Hello, world!'.repeat(20)); + assert.strictEqual(byName.get('hello.txt').method, 8); + assert.deepStrictEqual(await byName.get('raw.bin').content(), Buffer.from([1, 2, 3, 4, 5])); + assert.strictEqual(byName.get('raw.bin').method, 0); + assert.strictEqual((await byName.get('empty.txt').content()).length, 0); + assert.strictEqual(byName.get('dir/').isDirectory, true); + assert.strictEqual(byName.get('hello.txt').isFile, true); +}); + +test('ZipBuffer indexes entries by name', async () => { + const entries = [ + await zlib.ZipEntry.create('a.txt', Buffer.from('a')), + await zlib.ZipEntry.create('b.txt', Buffer.from('b')), + ]; + const archive = await buildArchive(entries); + using zip = new zlib.ZipBuffer(archive); + + assert.strictEqual(zip.size, 2); + assert.strictEqual(zip.has('a.txt'), true); + assert.strictEqual(zip.has('missing.txt'), false); + assert.strictEqual((await zip.get('a.txt').content()).toString(), 'a'); + assert.deepStrictEqual([...zip.keys()].sort(), ['a.txt', 'b.txt']); + + assert.throws(() => zip.get('missing.txt'), { code: 'ERR_ZIP_ENTRY_NOT_FOUND' }); +}); + +test('an incompressible or empty entry falls back to store', async () => { + const random = incompressibleBytes(4096); + const entry = await zlib.ZipEntry.create('random.bin', random); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(await entry.content(), random); + + const empty = await zlib.ZipEntry.create('empty.bin', Buffer.alloc(0)); + assert.strictEqual(empty.method, 0); +}); + +test('explicit store option is honored even for compressible data', async () => { + const data = Buffer.from('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const entry = await zlib.ZipEntry.create('stored.txt', data, { method: 'store' }); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(entry.rawContent, data); +}); + +test('the zstd method compresses and round-trips through an archive', async () => { + const data = Buffer.from('zstd content '.repeat(200)); + const entry = await zlib.ZipEntry.create('z.txt', data, { method: 'zstd' }); + assert.strictEqual(entry.method, 93); + assert.ok(entry.compressedSize < data.length); + assert.deepStrictEqual(await entry.content(), data); + + const archive = await buildArchive([entry]); + const [read] = zlib.ZipEntry.read(archive); + assert.strictEqual(read.method, 93); + assert.deepStrictEqual(await read.content(), data); +}); + +test('an incompressible entry with method zstd falls back to store', async () => { + const random = incompressibleBytes(4096); + const entry = await zlib.ZipEntry.create('random.bin', random, { method: 'zstd' }); + assert.strictEqual(entry.method, 0); + assert.deepStrictEqual(await entry.content(), random); +}); + +test('crc32 chains the same way as zlib.crc32', async () => { + const data = Buffer.from('the quick brown fox jumps over the lazy dog'); + const entry = await zlib.ZipEntry.create('f.txt', data); + assert.strictEqual(entry.crc32, zlib.crc32(data)); +}); + +test('createZipArchive rejects an overlong comment', async () => { + await assert.rejects( + buildArchive([], 'x'.repeat(70000)), + { code: 'ERR_ZIP_ARCHIVE_TOO_LARGE' }, + ); +}); + +test('directory entries cannot carry content', async () => { + await assert.rejects( + zlib.ZipEntry.create('dir/', Buffer.from('x')), + { code: 'ERR_INVALID_ARG_VALUE' }, + ); +}); + +test('ZipEntry.createSymlink round-trips as a symlink entry', async () => { + const entry = zlib.ZipEntry.createSymlink('link', '../target', { mode: 0o777 }); + const [read] = zlib.ZipEntry.read(await buildArchive([entry])); + assert.strictEqual(read.isSymlink, true); + assert.strictEqual(read.isFile, false); + assert.strictEqual(read.mode, 0o777); + assert.strictEqual(read.contentSync().toString(), '../target'); +}); + +test('a deflate and a zstd member each stream back through contentIterator', async () => { + for (const method of ['deflate', 'zstd']) { + const data = Buffer.from(`${method} payload `.repeat(500)); + const entry = await zlib.ZipEntry.create('m.txt', data, { method }); + const [read] = zlib.ZipEntry.read(await buildArchive([entry])); + const chunks = []; + for await (const chunk of read.contentIterator()) chunks.push(chunk); + assert.deepStrictEqual(Buffer.concat(chunks), data); + } +}); diff --git a/test/pummel/test-zlib-zip-slow.js b/test/pummel/test-zlib-zip-slow.js new file mode 100644 index 000000000000..c3e334c06dec --- /dev/null +++ b/test/pummel/test-zlib-zip-slow.js @@ -0,0 +1,129 @@ +'use strict'; + +// This test writes and reads back a ZIP archive whose total size exceeds +// 4 GiB, to exercise the Zip64 promotion that is triggered by an offset or +// size overflowing the classic 32-bit fields (as opposed to test-zlib-zip- +// zip64.js in test/parallel, which triggers Zip64 through the 16-bit entry +// count instead). It needs several GiB of free disk space and is too slow +// for the default test run, hence living in test/pummel rather than +// test/parallel. + +const common = require('../common'); + +const assert = require('node:assert'); +const zlib = require('node:zlib'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); +const { test } = require('node:test'); + +tmpdir.refresh(); + +const GiB = 1024 * 1024 * 1024; +const MEMBER_SIZE = 500 * 1024 * 1024; // Four ~500 MiB stored members... +const STORED_MEMBER_COUNT = 4; +const STREAMED_MEMBER_SIZE = 4.5 * GiB; // ...plus one >4 GiB streamed member: +// the total archive size (~6.5 GiB) pushes offsets over the 4 GiB Zip64 +// threshold, and the streamed member's own sizes exceed 32 bits too, so the +// per-entry Zip64 size fields (central header and data descriptor) are +// exercised as well as the offset promotion. Required free space includes +// generous slack over that total. +const REQUIRED_FREE_BYTES = 12 * GiB; +const CHUNK_SIZE = 16 * 1024 * 1024; + +function fillChunk(seed) { + const chunk = Buffer.allocUnsafe(CHUNK_SIZE); + chunk.fill(seed & 0xff); + return chunk; +} + +async function* repeatingChunks(totalSize, seed) { + let remaining = totalSize; + while (remaining > 0) { + const size = Math.min(CHUNK_SIZE, remaining); + const chunk = fillChunk(seed); + remaining -= size; + yield size === chunk.length ? chunk : chunk.subarray(0, size); + } +} + +test('an archive larger than 4 GiB round-trips and triggers Zip64 via offset', async () => { + let free; + try { + const stats = await fs.statfs(tmpdir.path); + free = stats.bavail * stats.bsize; + } catch { + free = undefined; + } + if (free !== undefined && free < REQUIRED_FREE_BYTES) { + common.skip(`insufficient disk space in ${tmpdir.path} for a >4 GiB archive test`); + return; + } + + const dir = await fs.mkdtemp(path.join(tmpdir.path, 'zlib-zip-slow-')); + const archivePath = path.join(dir, 'large.zip'); + try { + const entries = []; + for (let i = 0; i < STORED_MEMBER_COUNT; i++) { + entries.push(zlib.ZipEntry.createStream(`stored-${i}.bin`, repeatingChunks(MEMBER_SIZE, i), { + method: 'store', + })); + } + entries.push(zlib.ZipEntry.createStream('streamed.bin', repeatingChunks(STREAMED_MEMBER_SIZE, 0xaa), { + method: 'store', + })); + + const handle = await fs.open(archivePath, 'w'); + try { + for await (const chunk of zlib.createZipArchive(entries)) { + await handle.write(chunk); + } + } finally { + await handle.close(); + } + + const stat = await fs.stat(archivePath); + assert.ok(stat.size > 4 * GiB, `archive is only ${stat.size} bytes`); + + const zip = await zlib.ZipFile.open(archivePath); + try { + assert.strictEqual(zip.size, STORED_MEMBER_COUNT + 1); + + let seen = 0; + for await (const chunk of await zip.stream('streamed.bin')) { + seen += chunk.length; + assert.strictEqual(chunk[0], 0xaa); + } + assert.strictEqual(seen, STREAMED_MEMBER_SIZE); + + let storedSeen = 0; + for await (const chunk of await zip.stream('stored-2.bin')) { + storedSeen += chunk.length; + assert.strictEqual(chunk[0], 2); + } + assert.strictEqual(storedSeen, MEMBER_SIZE); + + // The streamed member's sizes genuinely exceed 32 bits (stored, so + // compressed === uncompressed), which the reader must have resolved + // through the central header's Zip64 extra field. + const big = await zip.get('streamed.bin'); + assert.strictEqual(big.size, STREAMED_MEMBER_SIZE); + assert.strictEqual(big.compressedSize, STREAMED_MEMBER_SIZE); + + // Re-serialize the >4 GiB file-backed member through the archive + // writer, discarding the output: this exercises the non-streaming + // Zip64 local-header path (real 64-bit sizes up front, no data + // descriptor) without needing a second copy on disk. + let reserialized = 0; + for await (const chunk of zlib.createZipArchive([big])) { + reserialized += chunk.length; + } + assert.ok(reserialized > STREAMED_MEMBER_SIZE, + `re-serialized only ${reserialized} bytes`); + } finally { + await zip.close(); + } + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } +}, { timeout: 30 * 60 * 1000 }); From b9dacd417fa4feae384892d8462d6740ec0e2c88 Mon Sep 17 00:00:00 2001 From: Seongeun Lee Date: Sat, 1 Aug 2026 17:47:38 +0900 Subject: [PATCH 20/22] test: reuse ffi.suffix instead of reimplementing it ffi-test-common.js duplicated the platform branch already exposed as ffi.suffix, risking drift if lib/ffi.js changes. Reuse it, and require it after skipIfFFIMissing() so a build without FFI skips instead of throwing. Also assert ffi.suffix resolves to the correct value per platform, since test-ffi-module.js only checked that the key exists. Signed-off-by: leah-1ee PR-URL: https://github.com/nodejs/node/pull/64840 Reviewed-By: Paolo Insogna Reviewed-By: Aviv Keller --- test/ffi/ffi-test-common.js | 9 +++------ test/ffi/test-ffi-module.js | 8 ++++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/test/ffi/ffi-test-common.js b/test/ffi/ffi-test-common.js index 86e56de8ec21..fa54ca2cfd34 100644 --- a/test/ffi/ffi-test-common.js +++ b/test/ffi/ffi-test-common.js @@ -6,18 +6,15 @@ const path = require('node:path'); common.skipIfFFIMissing(); +const { suffix } = require('node:ffi'); + const fixtureBuildDir = path.join( __dirname, 'fixture_library', 'build', common.buildType, ); -const libraryPath = path.join( - fixtureBuildDir, - process.platform === 'win32' ? 'ffi_test_library.dll' : - process.platform === 'darwin' ? 'ffi_test_library.dylib' : - 'ffi_test_library.so', -); +const libraryPath = path.join(fixtureBuildDir, `ffi_test_library.${suffix}`); function ensureFixtureLibrary() { if (!fs.existsSync(libraryPath)) { diff --git a/test/ffi/test-ffi-module.js b/test/ffi/test-ffi-module.js index ecbd6f1c6a98..7fe265972a74 100644 --- a/test/ffi/test-ffi-module.js +++ b/test/ffi/test-ffi-module.js @@ -154,6 +154,14 @@ test('ffi exports expected API surface', () => { assert.strictEqual(typeof ffi.types, 'object'); }); +test('ffi.suffix matches the current platform', () => { + const ffi = require('node:ffi'); + const expected = process.platform === 'win32' ? 'dll' : + process.platform === 'darwin' ? 'dylib' : 'so'; + + assert.strictEqual(ffi.suffix, expected); +}); + test('ffi.types exports canonical type constants', () => { const ffi = require('node:ffi'); const expected = { From a46087d7e8e5dfaecfdff6cb146f1b5fd2dc9411 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Sat, 1 Aug 2026 09:02:34 +0200 Subject: [PATCH 21/22] test: fix lint in dtls tests a7d16a8cc6e introduced destructured uses of assert and fixtures, which the test lint rules forbid, plus bare strictEqual/throws calls that were never imported and threw ReferenceError at runtime. Use the assert and fixtures namespaces directly. Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/64902 Reviewed-By: Mike McCready <66998419+MikeMcC399@users.noreply.github.com> Reviewed-By: Filip Skokan Reviewed-By: James M Snell --- test/parallel/test-dtls-accessors.mjs | 69 +++++++++---------- test/parallel/test-dtls-alpn.mjs | 4 +- test/parallel/test-dtls-ciphers.mjs | 17 ++--- test/parallel/test-dtls-client-cert.mjs | 15 ++-- .../test-dtls-connect-error-cleanup.mjs | 11 ++- .../test-dtls-destroy-in-callback.mjs | 8 +-- test/parallel/test-dtls-errors.mjs | 17 ++--- test/parallel/test-dtls-keylog.mjs | 13 ++-- test/parallel/test-dtls-mtu.mjs | 17 ++--- test/parallel/test-dtls-options.mjs | 6 +- .../test-dtls-reject-unauthorized.mjs | 11 ++- test/parallel/test-dtls-robustness.mjs | 8 +-- test/parallel/test-dtls-send.mjs | 17 ++--- .../test-dtls-session-table-cleanup.mjs | 17 ++--- test/parallel/test-dtls-srtp.mjs | 21 +++--- .../test-dtls-unhandled-rejection.mjs | 8 +-- test/parallel/test-dtls-verify-identity.mjs | 15 ++-- 17 files changed, 116 insertions(+), 158 deletions(-) diff --git a/test/parallel/test-dtls-accessors.mjs b/test/parallel/test-dtls-accessors.mjs index 41c7ca7b2382..bb93eeaebe6e 100644 --- a/test/parallel/test-dtls-accessors.mjs +++ b/test/parallel/test-dtls-accessors.mjs @@ -9,9 +9,6 @@ import { import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { strictEqual } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -22,9 +19,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); const gotServerSession = Promise.withResolvers(); @@ -34,26 +31,26 @@ const server = listen(mustCall((session) => { // --- Endpoint state after listen(): bound and listening. --- const es = server.state; -strictEqual(es.bound, true); -strictEqual(es.listening, true); -strictEqual(es.closing, false); -strictEqual(es.destroyed, false); -strictEqual(es.sessionCount, 0); +assert.strictEqual(es.bound, true); +assert.strictEqual(es.listening, true); +assert.strictEqual(es.closing, false); +assert.strictEqual(es.destroyed, false); +assert.strictEqual(es.sessionCount, 0); // The busy property is settable via the endpoint and reflected in the state view. -strictEqual(server.busy, false); -strictEqual(es.busy, false); +assert.strictEqual(server.busy, false); +assert.strictEqual(es.busy, false); server.busy = true; -strictEqual(server.busy, true); -strictEqual(es.busy, true); +assert.strictEqual(server.busy, true); +assert.strictEqual(es.busy, true); server.busy = false; -strictEqual(es.busy, false); +assert.strictEqual(es.busy, false); // --- Endpoint onerror accessor. --- -strictEqual(server.onerror, undefined); +assert.strictEqual(server.onerror, undefined); const onEndpointError = mustNotCall(); server.onerror = onEndpointError; -strictEqual(server.onerror, onEndpointError); +assert.strictEqual(server.onerror, onEndpointError); const client = connect('127.0.0.1', server.address.port, { ca: [ca], @@ -62,43 +59,43 @@ const client = connect('127.0.0.1', server.address.port, { // --- Session state during the handshake. --- const cs = client.state; -strictEqual(cs.handshaking, true); -strictEqual(cs.open, false); -strictEqual(cs.closing, false); -strictEqual(cs.destroyed, false); -strictEqual(cs.hasMessageListener, false); +assert.strictEqual(cs.handshaking, true); +assert.strictEqual(cs.open, false); +assert.strictEqual(cs.closing, false); +assert.strictEqual(cs.destroyed, false); +assert.strictEqual(cs.hasMessageListener, false); // --- Session callback accessors: unset, then set. --- -strictEqual(client.onmessage, undefined); -strictEqual(client.onerror, undefined); -strictEqual(client.onhandshake, undefined); -strictEqual(client.onkeylog, undefined); +assert.strictEqual(client.onmessage, undefined); +assert.strictEqual(client.onerror, undefined); +assert.strictEqual(client.onhandshake, undefined); +assert.strictEqual(client.onkeylog, undefined); // A connect() session owns its internal endpoint. -strictEqual(client.ownsEndpoint, true); +assert.strictEqual(client.ownsEndpoint, true); client.onmessage = mustNotCall(); -strictEqual(typeof client.onmessage, 'function'); +assert.strictEqual(typeof client.onmessage, 'function'); // Attaching a message listener flips the shared flag. -strictEqual(cs.hasMessageListener, true); +assert.strictEqual(cs.hasMessageListener, true); client.onerror = mustNotCall(); -strictEqual(typeof client.onerror, 'function'); +assert.strictEqual(typeof client.onerror, 'function'); client.onhandshake = mustCall(); -strictEqual(typeof client.onhandshake, 'function'); +assert.strictEqual(typeof client.onhandshake, 'function'); client.onkeylog = mustCallAtLeast(); -strictEqual(typeof client.onkeylog, 'function'); +assert.strictEqual(typeof client.onkeylog, 'function'); await client.opened; // --- Session state after the handshake completes. --- -strictEqual(cs.handshaking, false); -strictEqual(cs.open, true); +assert.strictEqual(cs.handshaking, false); +assert.strictEqual(cs.open, true); const serverSession = await gotServerSession.promise; await serverSession.opened; -strictEqual(es.sessionCount, 1); +assert.strictEqual(es.sessionCount, 1); await client.close(); await server.close(); diff --git a/test/parallel/test-dtls-alpn.mjs b/test/parallel/test-dtls-alpn.mjs index 420cc3331b62..01054dd6432b 100644 --- a/test/parallel/test-dtls-alpn.mjs +++ b/test/parallel/test-dtls-alpn.mjs @@ -74,8 +74,8 @@ await endpoint.close(); const serverSession = await gotServerSession.promise; await serverSession.opened; - strictEqual(client.alpnProtocol, undefined); - strictEqual(serverSession.alpnProtocol, undefined); + assert.strictEqual(client.alpnProtocol, undefined); + assert.strictEqual(serverSession.alpnProtocol, undefined); await client.close(); await server.close(); diff --git a/test/parallel/test-dtls-ciphers.mjs b/test/parallel/test-dtls-ciphers.mjs index e58a1443c9c7..074c33f64b9b 100644 --- a/test/parallel/test-dtls-ciphers.mjs +++ b/test/parallel/test-dtls-ciphers.mjs @@ -6,9 +6,6 @@ import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { strictEqual, throws } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -19,9 +16,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256'; @@ -43,15 +40,15 @@ const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256'; const serverSession = await gotServerSession.promise; await serverSession.opened; - strictEqual(client.cipher.name, CIPHER); - strictEqual(serverSession.cipher.name, CIPHER); + assert.strictEqual(client.cipher.name, CIPHER); + assert.strictEqual(serverSession.cipher.name, CIPHER); await client.close(); await server.close(); } // Case 2: an invalid cipher list is rejected. -throws(() => listen(mustNotCall(), { +assert.throws(() => listen(mustNotCall(), { cert, key, port: 0, host: '127.0.0.1', ciphers: 'THIS-IS-NOT-A-CIPHER', }), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); @@ -74,6 +71,6 @@ throws(() => listen(mustNotCall(), { } // Case 4: an invalid ECDH curve is rejected. -throws(() => listen(mustNotCall(), { +assert.throws(() => listen(mustNotCall(), { cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'not-a-curve', }), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); diff --git a/test/parallel/test-dtls-client-cert.mjs b/test/parallel/test-dtls-client-cert.mjs index 2505eb6ff7c2..271bd7e322ab 100644 --- a/test/parallel/test-dtls-client-cert.mjs +++ b/test/parallel/test-dtls-client-cert.mjs @@ -7,9 +7,6 @@ import { hasCrypto, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { ok, rejects } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -20,9 +17,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); // Case 1: the client presents a certificate the server can verify. { @@ -44,8 +41,8 @@ const ca = readKey('ca1-cert.pem').toString(); // The server received and verified the client's certificate. const clientCert = serverSession.peerCertificate; - ok(clientCert); - ok(clientCert.includes('BEGIN CERTIFICATE')); + assert.ok(clientCert); + assert.ok(clientCert.includes('BEGIN CERTIFICATE')); await client.close(); await server.close(); @@ -63,7 +60,7 @@ const ca = readKey('ca1-cert.pem').toString(); }); // The exact alert text varies, so assert only that the handshake is rejected. - await rejects(client.opened, { + await assert.rejects(client.opened, { message: /handshake failure/ }); diff --git a/test/parallel/test-dtls-connect-error-cleanup.mjs b/test/parallel/test-dtls-connect-error-cleanup.mjs index 049c24879007..13dcf8da26bc 100644 --- a/test/parallel/test-dtls-connect-error-cleanup.mjs +++ b/test/parallel/test-dtls-connect-error-cleanup.mjs @@ -8,9 +8,6 @@ import { hasCrypto, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { rejects } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -21,9 +18,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); // The client rejects the certificate mid-handshake, so this server session // never opens; its opened rejection is handled internally by the library. @@ -37,7 +34,7 @@ const session = connect('127.0.0.1', server.address.port, { servername: 'wrong.example.com', }); -await rejects(session.opened, /certificate verify failed/i); +await assert.rejects(session.opened, /certificate verify failed/i); // The failed connect must have closed its internally-owned endpoint. Without // that, this await never settles and the test times out. diff --git a/test/parallel/test-dtls-destroy-in-callback.mjs b/test/parallel/test-dtls-destroy-in-callback.mjs index 97344bb70719..7915742ca01d 100644 --- a/test/parallel/test-dtls-destroy-in-callback.mjs +++ b/test/parallel/test-dtls-destroy-in-callback.mjs @@ -9,8 +9,6 @@ import { hasCrypto, skip, mustCall } from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -21,9 +19,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); // --------------------------------------------------------------------------- // Case 1: destroy the (server) session from inside onmessage. The datagram diff --git a/test/parallel/test-dtls-errors.mjs b/test/parallel/test-dtls-errors.mjs index e17497fcac6c..e65c90abd4e0 100644 --- a/test/parallel/test-dtls-errors.mjs +++ b/test/parallel/test-dtls-errors.mjs @@ -7,9 +7,6 @@ import { hasCrypto, skip, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { throws } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -20,22 +17,22 @@ if (!process.features.dtls) { const { listen, DTLSEndpoint } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const mismatchedKey = readKey('agent2-key.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const mismatchedKey = fixtures.readKey('agent2-key.pem').toString(); // A malformed certificate PEM is rejected. -throws(() => listen(mustNotCall(), { +assert.throws(() => listen(mustNotCall(), { cert: 'not a certificate', key, port: 0, }), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); // A malformed private key PEM is rejected. -throws(() => listen(mustNotCall(), { +assert.throws(() => listen(mustNotCall(), { cert, key: 'not a key', port: 0, }), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); // A private key that does not match the certificate is rejected. -throws(() => listen(mustNotCall(), { +assert.throws(() => listen(mustNotCall(), { cert, key: mismatchedKey, port: 0, }), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); @@ -43,6 +40,6 @@ throws(() => listen(mustNotCall(), { { const endpoint = new DTLSEndpoint(); endpoint.bind('127.0.0.1', 0); - throws(() => endpoint.bind('127.0.0.1', 0), { code: 'ERR_INVALID_STATE' }); + assert.throws(() => endpoint.bind('127.0.0.1', 0), { code: 'ERR_INVALID_STATE' }); await endpoint.close(); } diff --git a/test/parallel/test-dtls-keylog.mjs b/test/parallel/test-dtls-keylog.mjs index 49d70a4566f5..f32c1142b4c6 100644 --- a/test/parallel/test-dtls-keylog.mjs +++ b/test/parallel/test-dtls-keylog.mjs @@ -7,9 +7,6 @@ import { hasCrypto, skip, mustCall, mustCallAtLeast } from '../common/index.mjs' import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { strictEqual, match } = assert; -const { readKey } = fixtures; - if (!hasCrypto) { skip('missing crypto'); } @@ -20,9 +17,9 @@ if (!process.features.dtls) { const { listen, connect } = await import('node:dtls'); -const cert = readKey('agent1-cert.pem').toString(); -const key = readKey('agent1-key.pem').toString(); -const ca = readKey('ca1-cert.pem').toString(); +const cert = fixtures.readKey('agent1-cert.pem').toString(); +const key = fixtures.readKey('agent1-key.pem').toString(); +const ca = fixtures.readKey('ca1-cert.pem').toString(); const gotKeylog = Promise.withResolvers(); @@ -37,8 +34,8 @@ const client = connect('127.0.0.1', server.address.port, { // A keylog line is "