Files
Philipp Dunkel 5ba72ae7bc zlib: add ZipEntry, ZipFile, and ZipBuffer
Add ZIP archive support to the node:zlib module through three classes
and a set of helpers:

- ZipEntry: a single archive member, with buffered reads (content()),
  bounded-memory streaming reads (contentIterator()), and
  create()/createStream() for building members.
- ZipFile: random access to an archive backed by a file descriptor,
  reading members lazily without retaining their content and writing
  new members in place; opened with open()/openSync().
- ZipBuffer: a zero-copy, in-memory view over an archive already held
  in a Buffer.

createZipArchive() serializes a sequence of entries into an archive
byte stream, and setMaxZipContentSize() bounds the default in-memory
decompression size. Every operation has both an asynchronous and a
synchronous form.

Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
PR-URL: https://github.com/nodejs/node/pull/64339
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Aviv Keller <me@aviv.sh>
2026-08-01 08:47:28 +00:00

200 lines
7.4 KiB
JavaScript

'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,
};