diff --git a/eslint.config.js b/eslint.config.js index 6501071..b7e7d6c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,6 +15,8 @@ export default [ rules: { "@typescript-eslint/no-floating-promises": "off", // `describe` and `it` return promises + "@typescript-eslint/no-non-null-asserted-optional-chain": "off", // easier for testing + "@typescript-eslint/no-non-null-assertion": "off", // easier for testing "n/no-unsupported-features/node-builtins": "off", // so we can use node:test "unicorn/no-abusive-eslint-disable": "off", }, diff --git a/src/node/writer.test.ts b/src/node/writer.test.ts index 63efaad..9f8857a 100644 --- a/src/node/writer.test.ts +++ b/src/node/writer.test.ts @@ -4,16 +4,16 @@ import { join } from "node:path"; import { Writable, type StreamOptions } from "node:stream"; import { buffer } from "node:stream/consumers"; import { describe, it, mock } from "node:test"; -import { GeneralPurposeFlags } from "../common.js"; -import { CompressionMethod } from "../core/compression-core.js"; -import { ZipPlatform, ZipVersion } from "../core/constants.js"; import { + CompressionMethod, DosFileAttributes, + GeneralPurposeFlags, UnixFileAttributes, -} from "../core/file-attributes.js"; + ZipPlatform, + ZipVersion, +} from "../common.js"; import { assertBufferEqual, assertInstanceOf } from "../test-util/assert.js"; import { - bigUint, cp437, cp437length, crc32, @@ -22,17 +22,198 @@ import { deflateLength32, dosDate, longUint, + mockAsyncTransform, shortUint, tinyUint, utf8, - utf8length, utf8length32, } from "../test-util/data.js"; import { getTemporaryOutputDirectory } from "../test-util/fixtures.js"; +import { defaultCompressors } from "./compression.js"; import { ZipWriter } from "./writer.js"; describe("node/writer", () => { - describe("ZipWriter", () => { + describe("class ZipWriter", () => { + describe("constructor", () => { + it("defaults to using the default compressors for node", async (t) => { + const compressorMock = t.mock.fn<(input: Uint8Array) => Uint8Array>( + () => Buffer.from("compressed!"), + ); + t.mock.method( + defaultCompressors, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CompressionMethod.Deflate.toString() as any, + mockAsyncTransform(compressorMock), + ); + + const writer = new ZipWriter(); + + await writer.addFile( + { + path: "hello.txt", + lastModified: new Date("2005-03-09T12:55:15Z"), + }, + "hello world", + ); + + await writer.finalize(); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + cp437`hello.txt`, // file name + "", // extra field + + //## +0039 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`compressed!`, + + //## +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`compressed!`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`compressed!`, // compressed size + utf8length32`hello world`, // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + cp437`hello.txt`, // file name + "", // extra field + "", // the comment + + //## +0121 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(121 - 66), // size of the central directory + longUint(66), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(await buffer(writer), expected); + + assert.strictEqual(compressorMock.mock.callCount(), 1); + + assertBufferEqual( + compressorMock.mock.calls[0]?.arguments[0]!, + Buffer.from("hello world"), + ); + }); + + it("uses the provided compressors if provided", async (t) => { + const compressorMock = t.mock.fn<(input: Uint8Array) => Uint8Array>( + () => Buffer.from("COMPRESSED!"), + ); + + const writer = new ZipWriter({ + compressors: { + [CompressionMethod.Deflate]: mockAsyncTransform(compressorMock), + }, + }); + + await writer.addFile( + { + path: "hello.txt", + lastModified: new Date("2005-03-09T12:55:15Z"), + }, + "hello world", + ); + + await writer.finalize(); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + cp437`hello.txt`, // file name + "", // extra field + + //## +0039 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`COMPRESSED!`, + + //## +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`COMPRESSED!`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`COMPRESSED!`, // compressed size + utf8length32`hello world`, // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + cp437`hello.txt`, // file name + "", // extra field + "", // the comment + + //## +0121 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(121 - 66), // size of the central directory + longUint(66), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(await buffer(writer), expected); + + assert.strictEqual(compressorMock.mock.callCount(), 1); + + assertBufferEqual( + compressorMock.mock.calls[0]?.arguments[0]!, + Buffer.from("hello world"), + ); + }); + }); + describe(".fromWritable()", () => { it("throws an error if the stream fails", async () => { const error = new Error("bang"); @@ -367,405 +548,5 @@ describe("node/writer", () => { assertBufferEqual(await readFile(path), expected); }); }); - - it("produces the correct data", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); - - await writer.addFile( - { - path: "hello.txt", - lastModified: new Date(`2023-04-05T11:22:34Z`), - comment: "comment 1", - attributes: new UnixFileAttributes(0o644), - }, - "hello world", - ); - - await writer.addFile( - { - path: "uncompressed.txt", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date(`1994-03-02T22:44:08Z`), - comment: "comment 2", - }, - "this will be stored as-is", - ); - - await writer.finalize("Gordon is cool"); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437`hello.txt`, // file name - "", // extra field - - //// +0039 LOCAL ENTRY 1 CONTENT (13 bytes) - deflate`hello world`, - - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - deflateLength32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0068 LOCAL ENTRY 2 HEADER (30+16+0 = 46 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`uncompressed.txt`, // file name length - shortUint(0), // extra field length - cp437`uncompressed.txt`, // file name - "", // extra field - - //// +0114 LOCAL ENTRY 2 CONTENT (25 bytes) - utf8`this will be stored as-is`, - - //// +0139 LOCAL ENTRY 2 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`this will be stored as-is`, // crc - utf8length32`this will be stored as-is`, // compressed size - utf8length32`this will be stored as-is`, // uncompressed size - - //// +0155 DIRECTORY ENTRY 1 (46+9+0+9 = 64 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.UNIX), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - crc32`hello world`, // crc32 - deflateLength32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437length`comment 1`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(UnixFileAttributes.raw(UnixFileAttributes.File | 0o644)), // external file attributes - longUint(0), // relative offset of local header - cp437`hello.txt`, // file name - "", // extra field - cp437`comment 1`, // the comment - - //// +0219 DIRECTORY ENTRY 2 (46+16+0+9 = 71 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - crc32`this will be stored as-is`, // crc32 - utf8length32`this will be stored as-is`, // compressed size - utf8length32`this will be stored as-is`, // uncompressed size - cp437length`uncompressed.txt`, // file name length - shortUint(0), // extra field length - cp437length`comment 2`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(68), // relative offset of local header - cp437`uncompressed.txt`, // file name - "", // extra field - cp437`comment 2`, // the comment - - //// +0290 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(2), // total entries this disk - shortUint(2), // total entries all disks - longUint(290 - 155), // size of the central directory - longUint(155), // central directory offset - cp437length`Gordon is cool`, // .ZIP file comment length - cp437`Gordon is cool`, // .ZIP file comment - ); - - assertBufferEqual(await output, expected); - }); - - it("uses the current date time if lastModified is not provided", async (context) => { - context.mock.timers.enable({ apis: ["Date"] }); - context.mock.timers.setTime(new Date("2005-03-09T12:55:15Z").getTime()); - - const writer = new ZipWriter(); - const output = buffer(writer); - - await writer.addFile( - { - path: "hello.txt", - compressionMethod: CompressionMethod.Stored, - }, - "hello world", - ); - - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437`hello.txt`, // file name - "", // extra field - - //// +0039 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0), // relative offset of local header - cp437`hello.txt`, // file name - "", // extra field - "", // the comment - - //// +0121 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(121 - 66), // size of the central directory - longUint(66), // central directory offset - shortUint(0), // .ZIP file comment length - ); - - assertBufferEqual(await output, expected); - }); - - it("can write a utf8 entry", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); - - await writer.addFile( - { - path: "1️⃣.txt", - comment: "comment 1️⃣", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+11+0 = 41 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8`1️⃣.txt`, // file name - "", // extra field - - //// +0041 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0068 DIRECTORY ENTRY 1 (46+11+0+15 = 72 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Utf8Encoding), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8length`comment 1️⃣`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0), // relative offset of local header - utf8`1️⃣.txt`, // file name - "", // extra field - utf8`comment 1️⃣`, // the comment - - //// +0140 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(140 - 68), // size of the central directory - longUint(68), // central directory offset - shortUint(0), // .ZIP file comment length - ); - - assertBufferEqual(await output, expected); - }); - - it("can write a Zip64", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); - - await writer.addFile( - { - path: "hello.txt", - zip64: true, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - await writer.finalize("file comment"); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Zip64), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0xffff_ffff), // compressed size - longUint(0xffff_ffff), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(20), // extra field length - cp437`hello.txt`, // file name - - //// +0039 LOCAL ENTRY 1 EXTRA FIELDS (20 bytes) - shortUint(1), // Zip64 Extended Information Extra Field tag - shortUint(16), // size - bigUint(0), // uncompressed size - bigUint(0), // compressed size - - //// +0059 LOCAL ENTRY 1 CONTENT (13 bytes) - deflate`hello world`, - - //// +0072 LOCAL ENTRY 1 DATA DESCRIPTOR (24 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - bigUint(13), // compressed size - bigUint(11), // uncompressed size - - //// +0096 DIRECTORY ENTRY 1 (46+9 = 55 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Zip64), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Zip64), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - longUint(0xffff_ffff), // compressed size - longUint(0xffff_ffff), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(28), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0xffff_ffff), // relative offset of local header - cp437`hello.txt`, // file name - - //// +0151 DIRECTORY ENTRY 1 EXTRA FIELDS (28 bytes) - shortUint(1), // Zip64 Extended Information Extra Field tag - shortUint(24), // size - bigUint(11), // uncompressed size - bigUint(13), // compressed size - bigUint(0), // local header offset - - //// +0179 DIRECTORY ENTRY 1 COMMENT (0 bytes) - "", // the comment - - //// +0179 EOCDR64 (56 bytes) - longUint(0x06064b50), // EOCDR64 signature (0x06064b50) - bigUint(56 - 12), // record size (SizeOfFixedFields + SizeOfVariableData - 12) - tinyUint(ZipVersion.Zip64), // version made by - tinyUint(ZipPlatform.UNIX), // platform made by - shortUint(ZipVersion.Zip64), // version needed - longUint(0), // number of this disk - longUint(0), // central directory start disk - bigUint(1), // total entries this disk - bigUint(1), // total entries on all disks - bigUint(179 - 96), // size of the central directory - bigUint(96), // central directory offset - - //// +0234 EOCDL (20 bytes) - longUint(0x07064b50), // EOCDL signature - longUint(0), // start disk of Zip64 EOCDR - bigUint(179), // offset of Zip64 EOCDR - longUint(1), // total number of disks - - //// +0254 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0xffff), // number of this disk - shortUint(0xffff), // central directory start disk - shortUint(0xffff), // total entries this disk - shortUint(0xffff), // total entries all disks - longUint(0xffff_ffff), // size of the central directory - longUint(0xffff_ffff), // central directory offset - cp437length`file comment`, // .ZIP file comment length - - cp437`file comment`, - ); - - assertBufferEqual(await output, expected); - }); }); }); diff --git a/src/node/writer.ts b/src/node/writer.ts index ebac04a..1ff787f 100644 --- a/src/node/writer.ts +++ b/src/node/writer.ts @@ -1,27 +1,30 @@ -import { WriteStream, createWriteStream } from "node:fs"; +import { createWriteStream, type WriteStream } from "node:fs"; import type { CreateWriteStreamOptions } from "node:fs/promises"; import type { Writable } from "node:stream"; import { addAbortListener } from "../util/abort.js"; import { - ZipWriter as ZipWriterBase, - type ZipWriterOptions as ZipWriterBaseOptions, + type ZipWriterOptionsBase as ZipWriterOptionsBaseWeb, + type ZipWriterOptions as ZipWriterOptionsWeb, + ZipWriter as ZipWriterWeb, } from "../web/writer.js"; import { defaultCompressors } from "./compression.js"; -export type ZipWriterOptions = ZipWriterBaseOptions; +export type ZipWriterOptions = ZipWriterOptionsWeb; +export type ZipWriterOptionsBase = ZipWriterOptionsBaseWeb; -/** - * An object which can output a zip file. - */ -export class ZipWriter extends ZipWriterBase { - public static fromWritable(stream: Writable): ZipWriter { +export class ZipWriter extends ZipWriterWeb { + public static fromWritable( + stream: Writable, + options?: ZipWriterOptionsBase, + ): ZipWriter { const abort = new AbortController(); stream.once("error", (cause) => { abort.abort(cause); }); - return new this({ + return new ZipWriter({ + ...options, sink: { close: async () => { await awaitCallback((callback) => { @@ -38,14 +41,18 @@ export class ZipWriter extends ZipWriterBase { }); } - public static fromWriteStream(stream: WriteStream): ZipWriter { + public static fromWriteStream( + stream: WriteStream, + options?: ZipWriterOptionsBase, + ): ZipWriter { const abort = new AbortController(); stream.once("error", (cause) => { abort.abort(cause); }); - return new this({ + return new ZipWriter({ + ...options, sink: { close: async () => { await awaitCallback((callback) => { @@ -71,10 +78,10 @@ export class ZipWriter extends ZipWriterBase { ); } - public constructor(options?: ZipWriterOptions) { + public constructor(options: ZipWriterOptions = {}) { super({ - compressors: defaultCompressors, ...options, + compressors: options.compressors ?? defaultCompressors, }); } } diff --git a/src/test-util/assert.ts b/src/test-util/assert.ts index 3895c76..d4c2ce7 100644 --- a/src/test-util/assert.ts +++ b/src/test-util/assert.ts @@ -40,24 +40,26 @@ export class BufferAssertionError extends AssertionError { } export function assertBufferEqual( - actual: Uint8Array, - expected: Uint8Array, + actual: Uint8Array | Iterable, + expected: Uint8Array | Iterable, message?: string | AssertionErrorInfo, ): void { - assertInstanceOf(actual, Uint8Array, { - message: "expected `actual` to be a Uint8Array", - stackStartFn: assertBufferEqual, - }); - assertInstanceOf(expected, Uint8Array, { stackStartFn: assertBufferEqual }); + const actualBuffer = + actual instanceof Uint8Array ? actual : Buffer.concat(Array.from(actual)); - if (Buffer.compare(actual, expected) !== 0) { + const expectedBuffer = + expected instanceof Uint8Array + ? expected + : Buffer.concat(Array.from(expected)); + + if (Buffer.compare(actualBuffer, expectedBuffer) !== 0) { const options = typeof message === "string" ? { message } : message; throw new BufferAssertionError({ stackStartFn: assertBufferEqual, ...options, - actual, - expected, + actual: actualBuffer, + expected: expectedBuffer, }); } } diff --git a/src/test-util/data.ts b/src/test-util/data.ts index dc968f7..7ce3237 100644 --- a/src/test-util/data.ts +++ b/src/test-util/data.ts @@ -3,6 +3,7 @@ import { deflateRawSync } from "node:zlib"; import { CodePage437Encoder } from "../util/cp437.js"; import { computeCrc32 } from "../util/crc32.js"; import { DosDate } from "../util/dos-date.js"; +import { bufferFromIterable, type AsyncTransform } from "../util/streams.js"; // eslint-disable-next-line @typescript-eslint/require-await export async function* asyncIterable( @@ -108,6 +109,15 @@ export function longUint(value: number): Uint8Array { return buffer; } +export function mockAsyncTransform( + implementation: (input: Uint8Array) => Uint8Array | PromiseLike, +): AsyncTransform { + return async function* (input) { + const allInput = await bufferFromIterable(input); + yield await implementation(allInput); + }; +} + export function shortUint(value: number): Uint8Array { const buffer = Buffer.alloc(2); buffer.writeUint16LE(value); diff --git a/src/util/streams.test.ts b/src/util/streams.test.ts index 457573e..ed7bed2 100644 --- a/src/util/streams.test.ts +++ b/src/util/streams.test.ts @@ -434,6 +434,30 @@ describe("util/streams", () => { assertBufferEqual(output, expected); }); + it("iterates an Iterable of Uint8Array", async () => { + const output = await buffer( + normalizeDataSource([ + Buffer.from("one,"), + Buffer.from("two,"), + Buffer.from("three,"), + ]), + ); + + const expected = utf8`one,two,three,`; + + assertBufferEqual(output, expected); + }); + + it("iterates an Iterable of string", async () => { + const output = await buffer( + normalizeDataSource(["one,", "two,", "three,"]), + ); + + const expected = utf8`one,two,three,`; + + assertBufferEqual(output, expected); + }); + it("makes a stream from a buffer", async () => { const output = await buffer(normalizeDataSource(utf8`hello world`)); const expected = utf8`hello world`; diff --git a/src/util/streams.ts b/src/util/streams.ts index ed0669f..65c28a8 100644 --- a/src/util/streams.ts +++ b/src/util/streams.ts @@ -41,6 +41,8 @@ export type DataSource = | string | AsyncIterable | AsyncIterable + | Iterable + | Iterable | ReadableStream | ReadableStream; @@ -134,9 +136,10 @@ export async function* normalizeDataSource( } else if (data instanceof Uint8Array) { yield data; } else { - const iterable = isAsyncIterable(data) - ? data - : iterableFromReadableStream(data); + const iterable = + isAsyncIterable(data) || isIterable(data) + ? data + : iterableFromReadableStream(data); const encoder = new TextEncoder(); @@ -269,7 +272,6 @@ export function isAsyncIterable( export function isIterable(value: unknown): value is Iterable { return hasExtraProperty(value, Symbol.iterator); -} /** } /** diff --git a/src/web/writer.test.ts b/src/web/writer.test.ts index ec4ca10..cc46cd4 100644 --- a/src/web/writer.test.ts +++ b/src/web/writer.test.ts @@ -1,13 +1,13 @@ import assert from "node:assert"; import { buffer } from "node:stream/consumers"; import { describe, it, mock } from "node:test"; -import { GeneralPurposeFlags } from "../common.js"; -import { CompressionMethod } from "../core/compression-core.js"; -import { ZipPlatform, ZipVersion } from "../core/constants.js"; import { + CompressionMethod, DosFileAttributes, - UnixFileAttributes, -} from "../core/file-attributes.js"; + GeneralPurposeFlags, + ZipPlatform, + ZipVersion, +} from "../common.js"; import { assertBufferEqual } from "../test-util/assert.js"; import { bigUint, @@ -15,10 +15,9 @@ import { cp437length, crc32, data, - deflate, - deflateLength32, dosDate, longUint, + mockAsyncTransform, shortUint, tinyUint, utf8, @@ -27,6 +26,7 @@ import { } from "../test-util/data.js"; import { computeCrc32 } from "../util/crc32.js"; import type { ByteSink } from "../util/streams.js"; +import { defaultCompressors } from "./compression.js"; import { ZipWriter } from "./writer.js"; describe("web/writer", () => { @@ -48,7 +48,6 @@ describe("web/writer", () => { path: "hello.txt", lastModified: new Date(`2023-04-05T11:22:34Z`), comment: "comment 1", - attributes: new UnixFileAttributes(0o644), }, "hello world", ); @@ -59,52 +58,209 @@ describe("web/writer", () => { ); }); - it("produces the correct data", async () => { - const chunks: Uint8Array[] = []; - const close = mock.fn>["close"]>(); + it("writes the correct data to the stream", async () => { + const sink = new MockSink(); + const writer = ZipWriter.fromWritableStream(new WritableStream(sink)); - const write = mock.fn>["write"]>( - (chunk) => { - chunks.push(chunk); + await writer.addFile( + { + path: "1️⃣.txt", + comment: "comment 1️⃣", + compressionMethod: CompressionMethod.Stored, + lastModified: new Date("2005-03-09T12:55:15Z"), }, + "hello world", ); - const writableStream = new WritableStream({ - close, - write, - }); + await writer.finalize(); - const writer = ZipWriter.fromWritableStream(writableStream); + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+11+0 = 41 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Utf8Encoding), // version needed + shortUint( + GeneralPurposeFlags.HasDataDescriptor | + GeneralPurposeFlags.HasUtf8Strings, + ), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + utf8length`1️⃣.txt`, // file name length + shortUint(0), // extra field length + utf8`1️⃣.txt`, // file name + "", // extra field + + //## +0041 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`hello world`, + + //## +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0068 DIRECTORY ENTRY 1 (46+11+0+15 = 72 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Utf8Encoding), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Utf8Encoding), // version needed + shortUint( + GeneralPurposeFlags.HasDataDescriptor | + GeneralPurposeFlags.HasUtf8Strings, + ), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + utf8length`1️⃣.txt`, // file name length + shortUint(0), // extra field length + utf8length`comment 1️⃣`, // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + utf8`1️⃣.txt`, // file name + "", // extra field + utf8`comment 1️⃣`, // the comment + + //## +0140 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(140 - 68), // size of the central directory + longUint(68), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(sink, expected); + assert.strictEqual(sink.close.mock.callCount(), 1); + }); + }); + + describe("constructor", () => { + it("defaults to using the default compressors for web", async (t) => { + const compressorMock = t.mock.fn<(input: Uint8Array) => Uint8Array>( + () => Buffer.from("compressed!"), + ); + t.mock.method( + defaultCompressors, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CompressionMethod.Deflate.toString() as any, + mockAsyncTransform(compressorMock), + ); + + const writer = new ZipWriter(); await writer.addFile( { path: "hello.txt", - lastModified: new Date(`2023-04-05T11:22:34Z`), - comment: "comment 1", - attributes: new UnixFileAttributes(0o644), + lastModified: new Date("2005-03-09T12:55:15Z"), }, "hello world", ); + await writer.finalize(); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + cp437`hello.txt`, // file name + "", // extra field + + //## +0039 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`compressed!`, + + //## +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`compressed!`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`compressed!`, // compressed size + utf8length32`hello world`, // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + cp437`hello.txt`, // file name + "", // extra field + "", // the comment + + //## +0121 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(121 - 66), // size of the central directory + longUint(66), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(await buffer(writer), expected); + + assert.strictEqual(compressorMock.mock.callCount(), 1); + + assertBufferEqual( + compressorMock.mock.calls[0]?.arguments[0]!, + Buffer.from("hello world"), + ); + }); + + it("uses the provided compressors if provided", async (t) => { + const compressorMock = t.mock.fn<(input: Uint8Array) => Uint8Array>( + () => Buffer.from("COMPRESSED!"), + ); + + const writer = new ZipWriter({ + compressors: { + [CompressionMethod.Deflate]: mockAsyncTransform(compressorMock), + }, + }); + await writer.addFile( { - path: "uncompressed.txt", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date(`1994-03-02T22:44:08Z`), - comment: "comment 2", + path: "hello.txt", + lastModified: new Date("2005-03-09T12:55:15Z"), }, - "this will be stored as-is", + "hello world", ); - await writer.finalize("Gordon is cool"); + await writer.finalize(); const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) + //## +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) longUint(0x04034b50), // local header signature shortUint(ZipVersion.Deflate), // version needed shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags shortUint(CompressionMethod.Deflate), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified + dosDate`2005-03-09T12:55:15Z`, // last modified longUint(0), // crc32 longUint(0), // compressed size longUint(0), // uncompressed size @@ -113,16 +269,119 @@ describe("web/writer", () => { cp437`hello.txt`, // file name "", // extra field - //// +0039 LOCAL ENTRY 1 CONTENT (13 bytes) - deflate`hello world`, + //## +0039 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`COMPRESSED!`, - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + //## +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) longUint(0x08074b50), // data descriptor signature crc32`hello world`, // crc - deflateLength32`hello world`, // compressed size + utf8length32`COMPRESSED!`, // compressed size utf8length32`hello world`, // uncompressed size - //// +0068 LOCAL ENTRY 2 HEADER (30+16+0 = 46 bytes) + //## +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Deflate), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`COMPRESSED!`, // compressed size + utf8length32`hello world`, // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + cp437`hello.txt`, // file name + "", // extra field + "", // the comment + + //## +0121 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(121 - 66), // size of the central directory + longUint(66), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(await buffer(writer), expected); + + assert.strictEqual(compressorMock.mock.callCount(), 1); + + assertBufferEqual( + compressorMock.mock.calls[0]?.arguments[0]!, + Buffer.from("hello world"), + ); + }); + }); + + describe("#[Symbol.asyncDispose]()", () => { + it("calls close on the sink", async () => { + const sink = new MockSink(); + const writer = new ZipWriter({ sink }); + + assert.strictEqual(sink.close.mock.callCount(), 0); + await writer[Symbol.asyncDispose](); + assert.strictEqual(sink.close.mock.callCount(), 1); + }); + }); + + describe("explicit resource management behavior", () => { + it("calls close on the sink", async () => { + const sink = new MockSink(); + + { + await using writer = new ZipWriter({ sink }); + + void writer; + assert.strictEqual(sink.close.mock.callCount(), 0); + } + + assert.strictEqual(sink.close.mock.callCount(), 1); + }); + }); + + describe("#[Symbol.asyncIterable]()", () => { + it("throws if the writer is in sink mode", async () => { + const sink = new MockSink(); + const writer = new ZipWriter({ sink }); + + await writer.addFile({ path: "folder/" }); + + assert.rejects( + () => buffer(writer), + (cause) => + cause instanceof Error && + cause.message === + `reading is not supported when initialized with sink`, + ); + }); + }); + + describe("#addFile()", () => { + it("outputs the correct data", async () => { + const sink = new MockSink(); + const writer = new ZipWriter({ sink }); + + await writer.addFile( + { + comment: "comment 2", + compressionMethod: CompressionMethod.Stored, + lastModified: new Date(`1994-03-02T22:44:08Z`), + path: "uncompressed.txt", + }, + "this will be stored as-is", + ); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+16+0 = 46 bytes) longUint(0x04034b50), // local header signature shortUint(ZipVersion.Deflate), // version needed shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags @@ -136,38 +395,132 @@ describe("web/writer", () => { cp437`uncompressed.txt`, // file name "", // extra field - //// +0114 LOCAL ENTRY 2 CONTENT (25 bytes) + //## +0046 LOCAL ENTRY 1 CONTENT (25 bytes) utf8`this will be stored as-is`, - //// +0139 LOCAL ENTRY 2 DATA DESCRIPTOR (16 bytes) + //## +0071 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) longUint(0x08074b50), // data descriptor signature crc32`this will be stored as-is`, // crc utf8length32`this will be stored as-is`, // compressed size utf8length32`this will be stored as-is`, // uncompressed size + ); - //// +0155 DIRECTORY ENTRY 1 (46+9+0+9 = 64 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.UNIX), // platform made by + assertBufferEqual(sink, expected); + }); + + it("skips the data descriptor when sizes and crc32 are given", async () => { + const sink = new MockSink(); + const writer = new ZipWriter({ sink }); + + const content = Buffer.from("hello world"); + const crc32 = computeCrc32(content); + + await writer.addFile( + { + path: "one.txt", + lastModified: new Date(`2023-04-05T11:22:34Z`), + crc32, + compressedSize: content.byteLength, + uncompressedSize: content.byteLength, + compressionMethod: CompressionMethod.Stored, + }, + content, + ); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+7+0 = 37 bytes) + longUint(0x04034b50), // local header signature shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method + shortUint(0), // flags + shortUint(CompressionMethod.Stored), // compression method dosDate`2023-04-05T11:22:34Z`, // last modified - crc32`hello world`, // crc32 - deflateLength32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - cp437length`hello.txt`, // file name length + longUint(crc32), // crc32 + longUint(11), // compressed size + longUint(11), // uncompressed size + cp437length`one.txt`, // file name length shortUint(0), // extra field length - cp437length`comment 1`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(UnixFileAttributes.raw(UnixFileAttributes.File | 0o644)), // external file attributes - longUint(0), // relative offset of local header - cp437`hello.txt`, // file name + cp437`one.txt`, // file name "", // extra field - cp437`comment 1`, // the comment - //// +0219 DIRECTORY ENTRY 2 (46+16+0+9 = 71 bytes) + //## +0037 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`hello world`, + ); + + assertBufferEqual(sink, expected); + }); + + it("throws if finalize() has already been called", async () => { + const writer = new ZipWriter(); + + await writer.finalize(); + + await assert.rejects( + async () => { + await writer.addFile({ path: "dir/" }); + }, + (error) => + error instanceof Error && + error.message === `can't add more files after calling finalize()`, + ); + }); + }); + + describe("#finalize()", () => { + it("throws if it has already been called", async () => { + const writer = new ZipWriter(); + + await writer.finalize(); + + await assert.rejects( + async () => { + await writer.finalize(); + }, + (error) => + error instanceof Error && + error.message === `multiple calls to finalize()`, + ); + }); + + it("writes the trailer", async () => { + const writer = new ZipWriter(); + + await writer.addFile( + { + comment: "comment 2", + compressionMethod: CompressionMethod.Stored, + lastModified: new Date(`1994-03-02T22:44:08Z`), + path: "uncompressed.txt", + }, + "this will be stored as-is", + ); + + await writer.finalize("Gordon is cool"); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+16+0 = 46 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`1994-03-02T22:44:08Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`uncompressed.txt`, // file name length + shortUint(0), // extra field length + cp437`uncompressed.txt`, // file name + "", // extra field + + //## +0046 LOCAL ENTRY 1 CONTENT (25 bytes) + utf8`this will be stored as-is`, + + //## +0071 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`this will be stored as-is`, // crc + utf8length32`this will be stored as-is`, // compressed size + utf8length32`this will be stored as-is`, // uncompressed size + + //## +0087 DIRECTORY ENTRY 1 (46+16+0+9 = 71 bytes) longUint(0x02014b50), // central directory header signature tinyUint(ZipVersion.Deflate), // version made by tinyUint(ZipPlatform.DOS), // platform made by @@ -184,747 +537,430 @@ describe("web/writer", () => { shortUint(0), // disk number start shortUint(0), // internal file attributes longUint(DosFileAttributes.File), // external file attributes - longUint(68), // relative offset of local header + longUint(0), // relative offset of local header cp437`uncompressed.txt`, // file name "", // extra field cp437`comment 2`, // the comment - //// +0290 End of Central Directory Record + //## +0158 End of Central Directory Record longUint(0x06054b50), // EOCDR signature shortUint(0), // number of this disk shortUint(0), // central directory start disk - shortUint(2), // total entries this disk - shortUint(2), // total entries all disks - longUint(290 - 155), // size of the central directory - longUint(155), // central directory offset + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(158 - 87), // size of the central directory + longUint(87), // central directory offset cp437length`Gordon is cool`, // .ZIP file comment length cp437`Gordon is cool`, // .ZIP file comment ); - assertBufferEqual(data(...chunks), expected); - assert.strictEqual(close.mock.callCount(), 1); + assertBufferEqual(await buffer(writer), expected); }); }); - it("produces the correct data", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); + describe("data generation", () => { + it("uses the current date time if lastModified is not provided", async (t) => { + t.mock.timers.enable({ apis: ["Date"] }); + t.mock.timers.setTime(new Date("2005-03-09T12:55:15Z").getTime()); - await writer.addFile( - { - path: "hello.txt", - lastModified: new Date(`2023-04-05T11:22:34Z`), - comment: "comment 1", - attributes: new UnixFileAttributes(0o644), - }, - "hello world", - ); - - await writer.addFile( - { - path: "uncompressed.txt", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date(`1994-03-02T22:44:08Z`), - comment: "comment 2", - }, - "this will be stored as-is", - ); - - await writer.finalize("Gordon is cool"); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437`hello.txt`, // file name - "", // extra field - - //// +0039 LOCAL ENTRY 1 CONTENT (13 bytes) - deflate`hello world`, - - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - deflateLength32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0068 LOCAL ENTRY 2 HEADER (30+16+0 = 46 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`uncompressed.txt`, // file name length - shortUint(0), // extra field length - cp437`uncompressed.txt`, // file name - "", // extra field - - //// +0114 LOCAL ENTRY 2 CONTENT (25 bytes) - utf8`this will be stored as-is`, - - //// +0139 LOCAL ENTRY 2 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`this will be stored as-is`, // crc - utf8length32`this will be stored as-is`, // compressed size - utf8length32`this will be stored as-is`, // uncompressed size - - //// +0155 DIRECTORY ENTRY 1 (46+9+0+9 = 64 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.UNIX), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - crc32`hello world`, // crc32 - deflateLength32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437length`comment 1`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(UnixFileAttributes.raw(UnixFileAttributes.File | 0o644)), // external file attributes - longUint(0), // relative offset of local header - cp437`hello.txt`, // file name - "", // extra field - cp437`comment 1`, // the comment - - //// +0219 DIRECTORY ENTRY 2 (46+16+0+9 = 71 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - crc32`this will be stored as-is`, // crc32 - utf8length32`this will be stored as-is`, // compressed size - utf8length32`this will be stored as-is`, // uncompressed size - cp437length`uncompressed.txt`, // file name length - shortUint(0), // extra field length - cp437length`comment 2`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(68), // relative offset of local header - cp437`uncompressed.txt`, // file name - "", // extra field - cp437`comment 2`, // the comment - - //// +0290 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(2), // total entries this disk - shortUint(2), // total entries all disks - longUint(290 - 155), // size of the central directory - longUint(155), // central directory offset - cp437length`Gordon is cool`, // .ZIP file comment length - cp437`Gordon is cool`, // .ZIP file comment - ); - - assertBufferEqual(await output, expected); - }); + const writer = new ZipWriter(); - it("uses the current date time if lastModified is not provided", async (context) => { - context.mock.timers.enable({ apis: ["Date"] }); - context.mock.timers.setTime(new Date("2005-03-09T12:55:15Z").getTime()); + await writer.addFile( + { + path: "hello.txt", + compressionMethod: CompressionMethod.Stored, + }, + "hello world", + ); - const writer = new ZipWriter(); - const output = buffer(writer); + await writer.finalize(); - await writer.addFile( - { - path: "hello.txt", - compressionMethod: CompressionMethod.Stored, - }, - "hello world", - ); - - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - cp437`hello.txt`, // file name - "", // extra field - - //// +0039 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(0), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0), // relative offset of local header - cp437`hello.txt`, // file name - "", // extra field - "", // the comment - - //// +0121 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(121 - 66), // size of the central directory - longUint(66), // central directory offset - shortUint(0), // .ZIP file comment length - ); - - assertBufferEqual(await output, expected); - }); + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+9+0 = 39 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + cp437`hello.txt`, // file name + "", // extra field - it("can write a utf8 entry", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); + //## +0039 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`hello world`, - await writer.addFile( - { - path: "1️⃣.txt", - comment: "comment 1️⃣", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+11+0 = 41 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8`1️⃣.txt`, // file name - "", // extra field - - //// +0041 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0068 DIRECTORY ENTRY 1 (46+11+0+15 = 72 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Utf8Encoding), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8length`comment 1️⃣`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0), // relative offset of local header - utf8`1️⃣.txt`, // file name - "", // extra field - utf8`comment 1️⃣`, // the comment - - //// +0140 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(140 - 68), // size of the central directory - longUint(68), // central directory offset - shortUint(0), // .ZIP file comment length - ); - - assertBufferEqual(await output, expected); - }); + //## +0050 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0066 DIRECTORY ENTRY 1 (46+9+0+0 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + cp437`hello.txt`, // file name + "", // extra field + "", // the comment - it("can write a Zip64", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); + //## +0121 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(121 - 66), // size of the central directory + longUint(66), // central directory offset + shortUint(0), // .ZIP file comment length + ); - await writer.addFile( - { - path: "hello.txt", - zip64: true, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - await writer.finalize("file comment"); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+9 = 39 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Zip64), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0xffff_ffff), // compressed size - longUint(0xffff_ffff), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(20), // extra field length - cp437`hello.txt`, // file name - - //// +0039 LOCAL ENTRY 1 EXTRA FIELDS (20 bytes) - shortUint(1), // Zip64 Extended Information Extra Field tag - shortUint(16), // size - bigUint(0), // uncompressed size - bigUint(0), // compressed size - - //// +0059 LOCAL ENTRY 1 CONTENT (13 bytes) - deflate`hello world`, - - //// +0072 LOCAL ENTRY 1 DATA DESCRIPTOR (24 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - bigUint(13), // compressed size - bigUint(11), // uncompressed size - - //// +0096 DIRECTORY ENTRY 1 (46+9 = 55 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Zip64), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Zip64), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Deflate), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - longUint(0xffff_ffff), // compressed size - longUint(0xffff_ffff), // uncompressed size - cp437length`hello.txt`, // file name length - shortUint(28), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0xffff_ffff), // relative offset of local header - cp437`hello.txt`, // file name - - //// +0151 DIRECTORY ENTRY 1 EXTRA FIELDS (28 bytes) - shortUint(1), // Zip64 Extended Information Extra Field tag - shortUint(24), // size - bigUint(11), // uncompressed size - bigUint(13), // compressed size - bigUint(0), // local header offset - - //// +0179 DIRECTORY ENTRY 1 COMMENT (0 bytes) - "", // the comment - - //// +0179 EOCDR64 (56 bytes) - longUint(0x06064b50), // EOCDR64 signature (0x06064b50) - bigUint(56 - 12), // record size (SizeOfFixedFields + SizeOfVariableData - 12) - tinyUint(ZipVersion.Zip64), // version made by - tinyUint(ZipPlatform.UNIX), // platform made by - shortUint(ZipVersion.Zip64), // version needed - longUint(0), // number of this disk - longUint(0), // central directory start disk - bigUint(1), // total entries this disk - bigUint(1), // total entries on all disks - bigUint(179 - 96), // size of the central directory - bigUint(96), // central directory offset - - //// +0234 EOCDL (20 bytes) - longUint(0x07064b50), // EOCDL signature - longUint(0), // start disk of Zip64 EOCDR - bigUint(179), // offset of Zip64 EOCDR - longUint(1), // total number of disks - - //// +0254 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0xffff), // number of this disk - shortUint(0xffff), // central directory start disk - shortUint(0xffff), // total entries this disk - shortUint(0xffff), // total entries all disks - longUint(0xffff_ffff), // size of the central directory - longUint(0xffff_ffff), // central directory offset - cp437length`file comment`, // .ZIP file comment length - - cp437`file comment`, - ); - - assertBufferEqual(await output, expected); - }); + assertBufferEqual(await buffer(writer), expected); + }); - it("defaults to CompressionMethod.Stored when content is empty", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); + it("can write a utf8 entry", async () => { + const writer = new ZipWriter(); - await writer.addFile( - { - path: "one.txt", - lastModified: new Date(`2023-04-05T11:22:34Z`), - }, - "", - ); - - await writer.addFile({ - path: "two.txt", - lastModified: new Date(`1994-03-02T22:44:08Z`), + await writer.addFile( + { + path: "1️⃣.txt", + comment: "comment 1️⃣", + compressionMethod: CompressionMethod.Stored, + lastModified: new Date("2005-03-09T12:55:15Z"), + }, + "hello world", + ); + + await writer.finalize(); + + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+11+0 = 41 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Utf8Encoding), // version needed + shortUint( + GeneralPurposeFlags.HasDataDescriptor | + GeneralPurposeFlags.HasUtf8Strings, + ), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + utf8length`1️⃣.txt`, // file name length + shortUint(0), // extra field length + utf8`1️⃣.txt`, // file name + "", // extra field + + //## +0041 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`hello world`, + + //## +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + + //## +0068 DIRECTORY ENTRY 1 (46+11+0+15 = 72 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Utf8Encoding), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Utf8Encoding), // version needed + shortUint( + GeneralPurposeFlags.HasDataDescriptor | + GeneralPurposeFlags.HasUtf8Strings, + ), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + utf8length32`hello world`, // compressed size + utf8length32`hello world`, // uncompressed size + utf8length`1️⃣.txt`, // file name length + shortUint(0), // extra field length + utf8length`comment 1️⃣`, // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0), // relative offset of local header + utf8`1️⃣.txt`, // file name + "", // extra field + utf8`comment 1️⃣`, // the comment + + //## +0140 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(1), // total entries this disk + shortUint(1), // total entries all disks + longUint(140 - 68), // size of the central directory + longUint(68), // central directory offset + shortUint(0), // .ZIP file comment length + ); + + assertBufferEqual(await buffer(writer), expected); }); - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+7+0 = 37 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`one.txt`, // file name length - shortUint(0), // extra field length - cp437`one.txt`, // file name - "", // extra field - - //// +0037 LOCAL ENTRY 1 CONTENT (0 bytes) - - //// +0037 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - - //// +0053 LOCAL ENTRY 2 HEADER (30+7+0 = 37 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`two.txt`, // file name length - shortUint(0), // extra field length - cp437`two.txt`, // file name - "", // extra field - - //// +0090 LOCAL ENTRY 2 CONTENT (0 bytes) - - //// +0090 LOCAL ENTRY 2 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - - //// +0106 DIRECTORY ENTRY 1 (46+7+0+0 = 53 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`one.txt`, // file name length - shortUint(0), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(0), // external file attributes - longUint(0), // relative offset of local header - cp437`one.txt`, // file name - "", // extra field - "", // the comment - - //// +0159 DIRECTORY ENTRY 2 (46+7+0+0 = 53 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`1994-03-02T22:44:08Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - cp437length`two.txt`, // file name length - shortUint(0), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(0), // external file attributes - longUint(53), // relative offset of local header - cp437`two.txt`, // file name - "", // extra field - "", // the comment - - //// +0212 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(2), // total entries this disk - shortUint(2), // total entries all disks - longUint(212 - 106), // size of the central directory - longUint(106), // central directory offset - shortUint(0), // .ZIP file comment length - "", // .ZIP file comment - ); - - assertBufferEqual(await output, expected); - }); + it("can write a Zip64", async () => { + const writer = new ZipWriter(); - it("skips the data descriptor when sizes and crc32 are given", async () => { - const writer = new ZipWriter(); - const output = buffer(writer); + await writer.addFile( + { + path: "hello.txt", + zip64: true, + lastModified: new Date("2005-03-09T12:55:15Z"), + compressionMethod: CompressionMethod.Stored, + }, + "hello world", + ); - const content = Buffer.from("hello world"); - const crc32 = computeCrc32(content); + await writer.finalize("file comment"); - await writer.addFile( - { - path: "one.txt", - lastModified: new Date(`2023-04-05T11:22:34Z`), - crc32, - compressedSize: content.byteLength, - uncompressedSize: content.byteLength, - compressionMethod: CompressionMethod.Stored, - }, - content, - ); - - await writer.finalize(); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+7+0 = 37 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Deflate), // version needed - shortUint(0), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(crc32), // crc32 - longUint(11), // compressed size - longUint(11), // uncompressed size - cp437length`one.txt`, // file name length - shortUint(0), // extra field length - cp437`one.txt`, // file name - "", // extra field - - //// +0037 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0048 DIRECTORY ENTRY 1 (46+7+0+0 = 53 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Deflate), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Deflate), // version needed - shortUint(0), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2023-04-05T11:22:34Z`, // last modified - longUint(crc32), // crc32 - longUint(11), // compressed size - longUint(11), // uncompressed size - cp437length`one.txt`, // file name length - shortUint(0), // extra field length - shortUint(0), // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(0), // external file attributes - longUint(0), // relative offset of local header - cp437`one.txt`, // file name - "", // extra field - "", // the comment - - //// +0101 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(101 - 48), // size of the central directory - longUint(48), // central directory offset - shortUint(0), // .ZIP file comment length - "", // .ZIP file comment - ); - - assertBufferEqual(await output, expected); - }); + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+9 = 39 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Zip64), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + longUint(0), // crc32 + longUint(0xffff_ffff), // compressed size + longUint(0xffff_ffff), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(20), // extra field length + cp437`hello.txt`, // file name - it("writes directly to a sink if given", async () => { - const writtenChunks: Uint8Array[] = []; - const close = mock.fn(); + //## +0039 LOCAL ENTRY 1 EXTRA FIELDS (20 bytes) + shortUint(1), // Zip64 Extended Information Extra Field tag + shortUint(16), // size + bigUint(0), // uncompressed size + bigUint(0), // compressed size + + //## +0059 LOCAL ENTRY 1 CONTENT (11 bytes) + utf8`hello world`, + + //## +0072 LOCAL ENTRY 1 DATA DESCRIPTOR (24 bytes) + longUint(0x08074b50), // data descriptor signature + crc32`hello world`, // crc + bigUint(11), // compressed size + bigUint(11), // uncompressed size + + //## +0094 DIRECTORY ENTRY 1 (46+9 = 55 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Zip64), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Zip64), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2005-03-09T12:55:15Z`, // last modified + crc32`hello world`, // crc32 + longUint(0xffff_ffff), // compressed size + longUint(0xffff_ffff), // uncompressed size + cp437length`hello.txt`, // file name length + shortUint(28), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(DosFileAttributes.File), // external file attributes + longUint(0xffff_ffff), // relative offset of local header + cp437`hello.txt`, // file name + + //## +0149 DIRECTORY ENTRY 1 EXTRA FIELDS (28 bytes) + shortUint(1), // Zip64 Extended Information Extra Field tag + shortUint(24), // size + bigUint(11), // uncompressed size + bigUint(11), // compressed size + bigUint(0), // local header offset + + //## +0177 DIRECTORY ENTRY 1 COMMENT (0 bytes) + "", // the comment + + //## +0177 EOCDR64 (56 bytes) + longUint(0x06064b50), // EOCDR64 signature (0x06064b50) + bigUint(56 - 12), // record size (SizeOfFixedFields + SizeOfVariableData - 12) + tinyUint(ZipVersion.Zip64), // version made by + tinyUint(ZipPlatform.UNIX), // platform made by + shortUint(ZipVersion.Zip64), // version needed + longUint(0), // number of this disk + longUint(0), // central directory start disk + bigUint(1), // total entries this disk + bigUint(1), // total entries on all disks + bigUint(177 - 94), // size of the central directory + bigUint(94), // central directory offset + + //## +0232 EOCDL (20 bytes) + longUint(0x07064b50), // EOCDL signature + longUint(0), // start disk of Zip64 EOCDR + bigUint(177), // offset of Zip64 EOCDR + longUint(1), // total number of disks + + //## +0254 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0xffff), // number of this disk + shortUint(0xffff), // central directory start disk + shortUint(0xffff), // total entries this disk + shortUint(0xffff), // total entries all disks + longUint(0xffff_ffff), // size of the central directory + longUint(0xffff_ffff), // central directory offset + cp437length`file comment`, // .ZIP file comment length + + cp437`file comment`, + ); - // eslint-disable-next-line @typescript-eslint/require-await - const write = mock.fn(async (chunk) => { - writtenChunks.push(chunk); + assertBufferEqual(await buffer(writer), expected); }); - const sink: ByteSink = { - close, - write, - }; + it("defaults to CompressionMethod.Stored when content is empty", async () => { + const writer = new ZipWriter(); - const writer = new ZipWriter({ sink }); + await writer.addFile( + { + path: "one.txt", + lastModified: new Date(`2023-04-05T11:22:34Z`), + }, + "", + ); - await writer.addFile( - { - path: "1️⃣.txt", - comment: "comment 1️⃣", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - const expected = data( - //// +0000 LOCAL ENTRY 1 HEADER (30+11+0 = 41 bytes) - longUint(0x04034b50), // local header signature - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - longUint(0), // crc32 - longUint(0), // compressed size - longUint(0), // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8`1️⃣.txt`, // file name - "", // extra field - - //// +0041 LOCAL ENTRY 1 CONTENT (11 bytes) - utf8`hello world`, - - //// +0052 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) - longUint(0x08074b50), // data descriptor signature - crc32`hello world`, // crc - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - - //// +0068 DIRECTORY ENTRY 1 (46+11+0+15 = 72 bytes) - longUint(0x02014b50), // central directory header signature - tinyUint(ZipVersion.Utf8Encoding), // version made by - tinyUint(ZipPlatform.DOS), // platform made by - shortUint(ZipVersion.Utf8Encoding), // version needed - shortUint( - GeneralPurposeFlags.HasDataDescriptor | - GeneralPurposeFlags.HasUtf8Strings, - ), // flags - shortUint(CompressionMethod.Stored), // compression method - dosDate`2005-03-09T12:55:15Z`, // last modified - crc32`hello world`, // crc32 - utf8length32`hello world`, // compressed size - utf8length32`hello world`, // uncompressed size - utf8length`1️⃣.txt`, // file name length - shortUint(0), // extra field length - utf8length`comment 1️⃣`, // file comment length - shortUint(0), // disk number start - shortUint(0), // internal file attributes - longUint(DosFileAttributes.File), // external file attributes - longUint(0), // relative offset of local header - utf8`1️⃣.txt`, // file name - "", // extra field - utf8`comment 1️⃣`, // the comment - - //// +0140 End of Central Directory Record - longUint(0x06054b50), // EOCDR signature - shortUint(0), // number of this disk - shortUint(0), // central directory start disk - shortUint(1), // total entries this disk - shortUint(1), // total entries all disks - longUint(140 - 68), // size of the central directory - longUint(68), // central directory offset - shortUint(0), // .ZIP file comment length - ); - - await writer.finalize(); - - assert.strictEqual(close.mock.callCount(), 1); - assertBufferEqual(data(...writtenChunks), expected); - }); + await writer.addFile({ + path: "two.txt", + lastModified: new Date(`1994-03-02T22:44:08Z`), + }); - it("throws on attempts to read when initialized with sink", async () => { - const close = mock.fn(); - const write = mock.fn(() => Promise.resolve()); + await writer.finalize(); - const sink: ByteSink = { - close, - write, - }; + const expected = data( + //## +0000 LOCAL ENTRY 1 HEADER (30+7+0 = 37 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2023-04-05T11:22:34Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`one.txt`, // file name length + shortUint(0), // extra field length + cp437`one.txt`, // file name + "", // extra field - const writer = new ZipWriter({ sink }); + //## +0037 LOCAL ENTRY 1 CONTENT (0 bytes) - await writer.addFile( - { - path: "1️⃣.txt", - comment: "comment 1️⃣", - compressionMethod: CompressionMethod.Stored, - lastModified: new Date("2005-03-09T12:55:15Z"), - }, - "hello world", - ); - - await assert.rejects( - () => writer[Symbol.asyncIterator]().next(), - (cause) => cause instanceof TypeError, - ); + //## +0037 LOCAL ENTRY 1 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + + //## +0053 LOCAL ENTRY 2 HEADER (30+7+0 = 37 bytes) + longUint(0x04034b50), // local header signature + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`1994-03-02T22:44:08Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`two.txt`, // file name length + shortUint(0), // extra field length + cp437`two.txt`, // file name + "", // extra field + + //## +0090 LOCAL ENTRY 2 CONTENT (0 bytes) + + //## +0090 LOCAL ENTRY 2 DATA DESCRIPTOR (16 bytes) + longUint(0x08074b50), // data descriptor signature + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + + //## +0106 DIRECTORY ENTRY 1 (46+7+0+0 = 53 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`2023-04-05T11:22:34Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`one.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(0), // external file attributes + longUint(0), // relative offset of local header + cp437`one.txt`, // file name + "", // extra field + "", // the comment + + //## +0159 DIRECTORY ENTRY 2 (46+7+0+0 = 53 bytes) + longUint(0x02014b50), // central directory header signature + tinyUint(ZipVersion.Deflate), // version made by + tinyUint(ZipPlatform.DOS), // platform made by + shortUint(ZipVersion.Deflate), // version needed + shortUint(GeneralPurposeFlags.HasDataDescriptor), // flags + shortUint(CompressionMethod.Stored), // compression method + dosDate`1994-03-02T22:44:08Z`, // last modified + longUint(0), // crc32 + longUint(0), // compressed size + longUint(0), // uncompressed size + cp437length`two.txt`, // file name length + shortUint(0), // extra field length + shortUint(0), // file comment length + shortUint(0), // disk number start + shortUint(0), // internal file attributes + longUint(0), // external file attributes + longUint(53), // relative offset of local header + cp437`two.txt`, // file name + "", // extra field + "", // the comment + + //## +0212 End of Central Directory Record + longUint(0x06054b50), // EOCDR signature + shortUint(0), // number of this disk + shortUint(0), // central directory start disk + shortUint(2), // total entries this disk + shortUint(2), // total entries all disks + longUint(212 - 106), // size of the central directory + longUint(106), // central directory offset + shortUint(0), // .ZIP file comment length + "", // .ZIP file comment + ); + + assertBufferEqual(await buffer(writer), expected); + }); }); }); }); + +class MockSink implements ByteSink, Iterable { + public readonly chunks: Uint8Array[] = []; + + public readonly close = mock.fn(() => Promise.resolve()); + + public readonly write = mock.fn((chunk) => { + this.chunks.push(chunk); + return Promise.resolve(); + }); + + public *[Symbol.iterator](): Iterator { + yield* this.chunks; + } +} diff --git a/src/web/writer.ts b/src/web/writer.ts index 58eea32..949d3b8 100644 --- a/src/web/writer.ts +++ b/src/web/writer.ts @@ -1,84 +1,113 @@ +import { ZipEntry, ZipPlatform, ZipVersion } from "../common.js"; import { CentralDirectoryHeader } from "../core/central-directory-header.js"; import { - CompressionMethod, compress, + CompressionMethod, type CompressionAlgorithms, } from "../core/compression-core.js"; -import { ZipPlatform, ZipVersion } from "../core/constants.js"; import { DataDescriptor } from "../core/data-descriptor.js"; import { LocalFileHeader } from "../core/local-file-header.js"; -import { ZipEntry, type ZipEntryInfo } from "../core/zip-entry.js"; +import type { ZipEntryInfo } from "../core/zip-entry.js"; import { Eocdr, Zip64Eocdl, Zip64Eocdr } from "../core/zip-trailer.js"; -import { assert } from "../util/assert.js"; -import { DoubleEndedBuffer } from "../util/double-ended-buffer.js"; +import { + DoubleEndedBuffer, + type DoubleEndedBufferOptions, +} from "../util/double-ended-buffer.js"; import { Mutex } from "../util/mutex.js"; import type { ByteSink, DataSource } from "../util/streams.js"; import { defaultCompressors } from "./compression.js"; -export type ZipWriterOptions = { +export type ZipWriterOptionsBase = { compressors?: CompressionAlgorithms; - highWaterMark?: number; - sink?: ByteSink; startingOffset?: number; }; -export class ZipWriter implements AsyncIterable { - // eslint-disable-next-line n/no-unsupported-features/node-builtins - public static fromWritableStream(stream: WritableStream): ZipWriter { - return new this({ sink: stream.getWriter() }); +export type ZipStreamWriterOptions = ZipWriterOptionsBase & { + sink: ByteSink; +}; + +export type ZipBufferWriterOptions = ZipWriterOptionsBase & + DoubleEndedBufferOptions; + +export type ZipWriterOptions = ZipStreamWriterOptions | ZipBufferWriterOptions; + +export class ZipWriter implements AsyncDisposable, AsyncIterable { + public static fromWritableStream( + // eslint-disable-next-line n/no-unsupported-features/node-builtins + stream: WritableStream, + options?: ZipWriterOptionsBase, + ): ZipWriter { + return new ZipWriter({ + ...options, + sink: stream.getWriter(), + }); } - private readonly buffer?: DoubleEndedBuffer; + private readonly buffer: DoubleEndedBuffer | undefined; private readonly compressors: CompressionAlgorithms; private readonly directory: CentralDirectoryHeader[] = []; private readonly sink: ByteSink; private readonly startingOffset: number; private readonly writeLock = new Mutex(); + private isFinalized = false; private writtenBytes = 0; public constructor(options: ZipWriterOptions = {}) { const { - compressors, - highWaterMark = 0xa000, - sink, + compressors = defaultCompressors, + highWaterMark, + sink = new DoubleEndedBuffer({ highWaterMark }), startingOffset = 0, - } = options; + } = options as Partial; - if (sink) { - this.sink = sink; - } else { - this.buffer = new DoubleEndedBuffer({ highWaterMark }); - this.sink = this.buffer; + if (sink instanceof DoubleEndedBuffer) { + this.buffer = sink; } - this.compressors = compressors ?? defaultCompressors; + this.compressors = compressors; + this.sink = sink; this.startingOffset = startingOffset; } public async *[Symbol.asyncIterator](): AsyncIterator { - if (!this.buffer) { - throw new TypeError( - `reading is not supported when initialized with sink`, - ); + if (this.buffer === undefined) { + throw new Error(`reading is not supported when initialized with sink`); } yield* this.buffer; } + public async [Symbol.asyncDispose](): Promise { + await this.sink.close(); + } + + /** + * Add a file to the Zip. + */ public readonly addFile = this.writeLock.synchronize( - (file: ZipEntryInfo, content?: DataSource) => - this.writeFileEntry(file, content), + async (file: ZipEntryInfo, content?: DataSource) => { + if (this.isFinalized) { + throw new Error(`can't add more files after calling finalize()`); + } + await this.writeFileEntry(file, content); + }, ); + /** + * Finalize the zip with an optional comment. + */ public readonly finalize = this.writeLock.synchronize( async (fileComment?: string) => { + if (this.isFinalized) { + throw new Error(`multiple calls to finalize()`); + } + this.isFinalized = true; await this.writeCentralDirectory(fileComment); await this.sink.close(); }, ); private async write(chunk: Uint8Array): Promise { - assert(this.sink.write, `expected sink to have a write method`); await this.sink.write(chunk); this.writtenBytes += chunk.byteLength; }