mirror of
https://github.com/nodejs/node.git
synced 2026-11-09 15:11:11 -05:00
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>
245 lines
10 KiB
JavaScript
245 lines
10 KiB
JavaScript
'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);
|
|
});
|