@patrickdk77/archive-defuse 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -303,14 +303,34 @@ const result = JSON.parse(await zip.text('result.json'));
303
303
 
304
304
  - `name`, with "/" between folders. A name that ends in "/" is a folder.
305
305
  - `data`: bytes, text written as UTF-8, a stream such as a Node Readable, or a function that returns one of those. A function runs when the entry's turn comes, so a thousand downloads do not all open at once.
306
+ - `size`, a file's length in bytes, for a stream whose length you know, such as a GridFS file's. Content that comes to another size fails the archive.
306
307
  - `linkTarget` for a symbolic link, `mode` for the permission bits, `mtime` for the time, and `store: true` to keep a ZIP entry uncompressed, which the `mimetype` entry of an ODF document needs.
307
308
 
308
- ZIP, tar and 7z need an entry's size before its data, so an entry given as a stream is read first, into memory up to `memoryThreshold` (8 MiB) and into a temporary file past it. ZIP entries are deflated at `level` 6. ZIP and 7z store an entry that compression gains nothing on, or that would compress past `ratioLimit`, 1,000 to 1 by default, so their archives read back under the default limits. A compressed tar and the output of `compress` have no stored form. Content that compresses past 1,000 to 1, such as a long run of zeros, stops `openArchive`, `decompress` and the inspection with LIMIT/RATIO. Raise `limits.compressionRatio` to read it.
309
+ ZIP and 7z need a file's CRC and sizes before its data, so a file given as a stream is read first, into memory up to `memoryThreshold` (8 MiB) and into a temporary file past it. tar needs only the size. Bytes, text and a stream with `size` go straight out, and only a stream without one is read first. ZIP entries are deflated at `level` 6. ZIP and 7z store an entry that compression gains nothing on, or that would compress past `ratioLimit`, 1,000 to 1 by default, so their archives read back under the default limits. A compressed tar and the output of `compress` have no stored form. Content that compresses past 1,000 to 1, such as a long run of zeros, stops `openArchive`, `decompress` and the inspection with LIMIT/RATIO. Raise `limits.compressionRatio` to read it.
309
310
 
310
- `streaming: true` writes a ZIP archive the way archiver does, with no temporary file. Each file is deflated as its data arrives and goes straight to the sink, and a data descriptor after the data carries its CRC and sizes. Only zlib's buffers are held. A file with `store: true` and a link's target are held in memory, because a stored entry keeps its sizes in its header, and an empty file is stored. Two things of the default mode are lost. A file deflate cannot shrink is deflated anyway, and `ratioLimit` does not apply, since the entry is out before its ratio is known. An entry of 4 GiB or more gets eight-byte sizes in its descriptor. tar and 7z need each entry's size before its data, so `streaming` writes ZIP only. A 26 MB sheet given one row per chunk takes 0.75 seconds this way, and 1.2 in the default mode.
311
+ `streaming: true` writes a ZIP archive the way archiver does, with no temporary file. Each file is deflated as its data arrives and goes straight to the sink, and a data descriptor after the data carries its CRC and sizes. Only zlib's buffers are held. A file with `store: true` and a link's target are held in memory, because a stored entry keeps its sizes in its header, and an empty file is stored. Two things of the default mode are lost. A file deflate cannot shrink is deflated anyway, and `ratioLimit` does not apply, since the entry is out before its ratio is known. A file whose size is known, from `size` or because it is bytes or text, gets ZIP64 fields in its local header when deflating it could pass 4 GiB, which tells a reader the descriptor's sizes are eight bytes. A stream without a size gets none, because some readers refuse ZIP64 fields an entry does not need. If such a stream reaches 4 GiB, its descriptor holds eight-byte sizes anyway, as archiver writes it. tar needs no option to stream, and 7z writes its header from every entry after the data, so `streaming` writes ZIP only. A 26 MB sheet given one row per chunk takes 0.75 seconds this way, and 1.2 in the default mode.
311
312
 
312
313
  Small chunks of an entry are gathered into 64 KiB pieces before they are compressed, so data given one row at a time compresses as fast as data given in large pieces.
313
314
 
315
+ **Adding entries as you go.** `createArchiveWriter(format, options)` takes entries one at a time, the way archiver's `append` and `finalize` do, for code that finds them as it goes. `add(entry)` queues an entry and returns at once, so a synchronous callback can call it. Entries go out in the order added, and each waits in memory until its turn. `finish()` says no more entries come, and resolves once the end of the archive is written. `abort(reason)` stops the archive. `stream` is the archive as a Readable. Read it while you add entries, because the end of the archive is written only as the stream is read. A refused name, a failed stream or an abort fails the archive. The writer destroys `stream` with the error, `finish` rejects with it, and a later `add` throws a PackError whose `cause` is the error.
316
+
317
+ ```js
318
+ const { createArchiveWriter } = require('@patrickdk77/archive-defuse');
319
+
320
+ const zip = createArchiveWriter('zip', { streaming: true });
321
+ const sent = pipeline(zip.stream, res);
322
+ try {
323
+ zip.add({ name: 'mimetype', data: 'application/vnd.oasis.opendocument.text', store: true });
324
+ // Each picture goes into the archive as layout finds it, before content.xml exists.
325
+ const xml = render(doc, picture => zip.add({ name: `Pictures/${picture.name}`, data: picture.bytes }));
326
+ zip.add({ name: 'content.xml', data: xml });
327
+ zip.finish();
328
+ } catch (e) {
329
+ zip.abort(e);
330
+ }
331
+ await sent;
332
+ ```
333
+
314
334
  `createArchive` throws a PackError, before it writes the entry, for a name the inspection would change. That covers an empty name, a NUL, an absolute path, a drive letter, a `..` part with or without dots and spaces after it, an empty or `.` part, a backslash, a control character or one that reorders text, and a part Windows cannot create, such as `con.txt`, `report:v2.txt` or a part that ends in a dot or a space. It also refuses a name that leaves its folder once NFKC normalization or Windows best fit changes it. NFKC turns two fullwidth full stops, U+FF0E, into `..`. Best fit turns a yen sign, U+00A5, into a backslash when a program on Japanese Windows converts the name to code page 932. A name with a yen sign is still written unless that conversion takes it out of its folder. Duplicate names are written as given.
315
335
 
316
336
  When an entry's stream fails, the sink is aborted instead of closed, and the Readable is destroyed with the error, so an upload or an HTTP response never ends with a partial archive that looks whole.
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { detectArchive, detectArchiveSource } from './detect';
2
2
  export { disarmArchive, disarmArchiveSource, inspectArchive, inspectArchiveSource } from './engine';
3
3
  export { allFindingSpecs } from './findings';
4
4
  export { bufferSink, bufferSource, fileSink, fileSource, writableSink } from './io';
5
- export { type CompressFormat, compress, compressStream, compressTo, createArchive, createArchiveBytes, createArchiveStream, type PackData, type PackEntry, PackError, type PackFormat, type PackOptions, } from './pack';
5
+ export { type ArchiveWriter, type CompressFormat, compress, compressStream, compressTo, createArchive, createArchiveBytes, createArchiveStream, createArchiveWriter, type PackData, type PackEntry, PackError, type PackFormat, type PackOptions, } from './pack';
6
6
  export { archivePlugin, passThrough } from './plugins';
7
7
  export { COMBINATIONS, DEFAULT_BANDS, scoreFindings } from './score';
8
8
  export * from './types';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.openArchive = exports.decompress = exports.ArchiveReadError = exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.passThrough = exports.archivePlugin = exports.PackError = exports.createArchiveStream = exports.createArchiveBytes = exports.createArchive = exports.compressTo = exports.compressStream = exports.compress = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectArchiveSource = exports.inspectArchive = exports.disarmArchiveSource = exports.disarmArchive = exports.detectArchiveSource = exports.detectArchive = void 0;
17
+ exports.openArchive = exports.decompress = exports.ArchiveReadError = exports.scoreFindings = exports.DEFAULT_BANDS = exports.COMBINATIONS = exports.passThrough = exports.archivePlugin = exports.PackError = exports.createArchiveWriter = exports.createArchiveStream = exports.createArchiveBytes = exports.createArchive = exports.compressTo = exports.compressStream = exports.compress = exports.writableSink = exports.fileSource = exports.fileSink = exports.bufferSource = exports.bufferSink = exports.allFindingSpecs = exports.inspectArchiveSource = exports.inspectArchive = exports.disarmArchiveSource = exports.disarmArchive = exports.detectArchiveSource = exports.detectArchive = void 0;
18
18
  var detect_1 = require("./detect");
19
19
  Object.defineProperty(exports, "detectArchive", { enumerable: true, get: function () { return detect_1.detectArchive; } });
20
20
  Object.defineProperty(exports, "detectArchiveSource", { enumerable: true, get: function () { return detect_1.detectArchiveSource; } });
@@ -38,6 +38,7 @@ Object.defineProperty(exports, "compressTo", { enumerable: true, get: function (
38
38
  Object.defineProperty(exports, "createArchive", { enumerable: true, get: function () { return pack_1.createArchive; } });
39
39
  Object.defineProperty(exports, "createArchiveBytes", { enumerable: true, get: function () { return pack_1.createArchiveBytes; } });
40
40
  Object.defineProperty(exports, "createArchiveStream", { enumerable: true, get: function () { return pack_1.createArchiveStream; } });
41
+ Object.defineProperty(exports, "createArchiveWriter", { enumerable: true, get: function () { return pack_1.createArchiveWriter; } });
41
42
  Object.defineProperty(exports, "PackError", { enumerable: true, get: function () { return pack_1.PackError; } });
42
43
  var plugins_1 = require("./plugins");
43
44
  Object.defineProperty(exports, "archivePlugin", { enumerable: true, get: function () { return plugins_1.archivePlugin; } });
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import cjs from './index.js';
2
- export const { detectArchive, detectArchiveSource, disarmArchive, disarmArchiveSource, inspectArchive, inspectArchiveSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, compress, compressStream, compressTo, createArchive, createArchiveBytes, createArchiveStream, PackError, archivePlugin, passThrough, COMBINATIONS, DEFAULT_BANDS, scoreFindings, ArchiveReadError, decompress, openArchive, ArchiveCategory, ArchiveDetail, ArchiveRisk } = cjs;
2
+ export const { detectArchive, detectArchiveSource, disarmArchive, disarmArchiveSource, inspectArchive, inspectArchiveSource, allFindingSpecs, bufferSink, bufferSource, fileSink, fileSource, writableSink, compress, compressStream, compressTo, createArchive, createArchiveBytes, createArchiveStream, createArchiveWriter, PackError, archivePlugin, passThrough, COMBINATIONS, DEFAULT_BANDS, scoreFindings, ArchiveReadError, decompress, openArchive, ArchiveCategory, ArchiveDetail, ArchiveRisk } = cjs;
3
3
  export default cjs;
package/dist/pack.d.ts CHANGED
@@ -13,6 +13,13 @@ export interface PackEntry {
13
13
  * a caller open a stream only when its turn comes.
14
14
  */
15
15
  data?: PackData | (() => PackData | Promise<PackData>);
16
+ /**
17
+ * A file's size in bytes, for content given as a stream whose length the caller knows, such as a file in a database
18
+ * that records it. tar writes such a file as its data arrives instead of reading it into memory or a temporary file
19
+ * first, and streaming ZIP gives it ZIP64 fields only when its size needs them. Content that comes to another size
20
+ * fails the archive.
21
+ */
22
+ size?: number;
16
23
  /** The target of a symbolic link. */
17
24
  linkTarget?: string;
18
25
  /** Permission bits. Defaults to 0644 for a file and 0755 for a folder. */
@@ -44,8 +51,9 @@ export interface PackOptions {
44
51
  /** Folder for temporary files. Defaults to `os.tmpdir()`. */
45
52
  tempDir?: string;
46
53
  /**
47
- * Bytes of one entry held in memory before a temporary file takes over, default 8 MiB. ZIP, tar and 7z need an
48
- * entry's size before its data, so an entry given as a stream is read into memory or a temporary file first.
54
+ * Bytes of one entry held in memory before a temporary file takes over, default 8 MiB. An entry given as a stream
55
+ * is read into memory or a temporary file before it is written when the format needs something of it first: its
56
+ * CRC and sizes for ZIP without `streaming` and for 7z, and its size for tar when the entry gives no `size`.
49
57
  */
50
58
  memoryThreshold?: number;
51
59
  /** Stops the work when aborted: the sink is aborted and the call throws the signal's reason. */
@@ -54,7 +62,10 @@ export interface PackOptions {
54
62
  * ZIP only: write each file as its data arrives, deflated on the way, with its CRC and sizes in a data descriptor
55
63
  * after the data. Nothing goes to a temporary file. An entry with `store: true` is held in memory, since a stored
56
64
  * entry keeps its sizes in its header. `ratioLimit` does not apply, since an entry is written before its ratio is
57
- * known.
65
+ * known. A file whose size is known, from its `size` or because its content is bytes or text, gets ZIP64 fields in
66
+ * its local header only when deflating it could pass 4 GiB. A stream without a size gets none, since some readers
67
+ * refuse ZIP64 fields an entry does not need; should it reach 4 GiB, its data descriptor holds eight-byte sizes, as
68
+ * archiver writes one. tar writes a file of known size as it arrives without this option.
58
69
  */
59
70
  streaming?: boolean;
60
71
  }
@@ -71,6 +82,31 @@ export declare function createArchive(format: PackFormat, entries: Iterable<Pack
71
82
  export declare function createArchiveStream(format: PackFormat, entries: Iterable<PackEntry> | AsyncIterable<PackEntry>, options?: PackOptions): Readable;
72
83
  /** `createArchive` into memory. */
73
84
  export declare function createArchiveBytes(format: PackFormat, entries: Iterable<PackEntry> | AsyncIterable<PackEntry>, options?: PackOptions): Promise<Uint8Array>;
85
+ /** An archive that entries are added to one at a time, as archiver's `append` and `finalize` work. */
86
+ export interface ArchiveWriter {
87
+ /**
88
+ * The archive as it is written, to read or pipe while entries are added. Entries wait in memory until it is read.
89
+ * An error destroys it, so a reader never takes a partial archive for a whole one.
90
+ */
91
+ readonly stream: Readable;
92
+ /**
93
+ * Adds an entry after the ones added before it. Its data is read when its turn comes, and a function given as its
94
+ * data is called then. Throws a PackError once `finish` or `abort` has been called or the archive has failed.
95
+ */
96
+ add(entry: PackEntry): void;
97
+ /**
98
+ * Says no more entries come. Resolves once the end of the archive is written to `stream`, and rejects with the error
99
+ * the archive failed on. The end is written only as `stream` is read, so keep reading it while this waits.
100
+ */
101
+ finish(): Promise<void>;
102
+ /** Stops the archive: `stream` is destroyed with `reason`, and `finish` rejects with it. Does nothing once it has finished. */
103
+ abort(reason?: unknown): void;
104
+ }
105
+ /**
106
+ * `createArchiveStream` for a caller that finds its entries as it goes. An entry `createArchive` refuses, or data that
107
+ * fails, fails the archive: `finish` rejects with the error and `stream` is destroyed with it.
108
+ */
109
+ export declare function createArchiveWriter(format: PackFormat, options?: PackOptions): ArchiveWriter;
74
110
  /** Compresses `data` as one stream of `format` to `sink`, then closes it. */
75
111
  export declare function compressTo(format: CompressFormat, data: PackData, sink: ByteSink, options?: PackOptions): Promise<void>;
76
112
  /** `compressTo` as a Node Readable. */
package/dist/pack.js CHANGED
@@ -4,6 +4,7 @@ exports.PackError = void 0;
4
4
  exports.createArchive = createArchive;
5
5
  exports.createArchiveStream = createArchiveStream;
6
6
  exports.createArchiveBytes = createArchiveBytes;
7
+ exports.createArchiveWriter = createArchiveWriter;
7
8
  exports.compressTo = compressTo;
8
9
  exports.compressStream = compressStream;
9
10
  exports.compress = compress;
@@ -119,6 +120,12 @@ function settle(entry, options, now) {
119
120
  const kind = entry.kind ?? (raw.endsWith('/') ? 'directory' : entry.linkTarget !== undefined ? 'symlink' : 'file');
120
121
  if (kind === 'symlink' && entry.linkTarget === undefined)
121
122
  throw new PackError(`Symbolic link ${JSON.stringify(raw)} needs a linkTarget`);
123
+ if (entry.size !== undefined) {
124
+ if (kind !== 'file')
125
+ throw new PackError(`Entry ${JSON.stringify(raw)} is a ${kind}, and only a file takes a size`);
126
+ if (!Number.isSafeInteger(entry.size) || entry.size < 0)
127
+ throw new PackError(`Entry ${JSON.stringify(raw)} has a size of ${String(entry.size)}, which is not a whole number of bytes`);
128
+ }
122
129
  const name = kind === 'directory' ? (raw.endsWith('/') ? raw : `${raw}/`) : raw.replace(/\/+$/, '');
123
130
  for (const detail of (0, rules_1.nameDetails)(name, true)) {
124
131
  const why = REFUSED[detail];
@@ -135,21 +142,51 @@ function settle(entry, options, now) {
135
142
  }
136
143
  return { name, kind, entry, mode: (entry.mode ?? (kind === 'directory' ? 0o755 : kind === 'symlink' ? 0o777 : 0o644)) & 0o7777, mtime: entry.mtime ?? options.mtime ?? now };
137
144
  }
138
- /** The content of a settled entry: a file's data, a link's target, or nothing for a folder. */
145
+ /** `chunks`, failing as soon as they pass `size` bytes, or when they end short of it. */
146
+ async function* sized(chunks, size, name) {
147
+ let total = 0;
148
+ for await (const c of chunks) {
149
+ total += c.length;
150
+ if (total > size)
151
+ throw new Error(`Entry ${JSON.stringify(name)} has more than the ${size} bytes its size gives`);
152
+ yield c;
153
+ }
154
+ if (total !== size)
155
+ throw new Error(`Entry ${JSON.stringify(name)} has ${total} bytes, not the ${size} its size gives`);
156
+ }
157
+ /**
158
+ * The content of a settled entry: a file's data, a link's target, or nothing for a folder. A file's size is known when
159
+ * the entry gives one or its content is bytes or text. Content given as bytes or text of another size than the entry
160
+ * gives throws a PackError here, before the entry is written; a stream of another size fails as it is read.
161
+ */
139
162
  async function contentOf(s) {
140
163
  if (s.kind === 'directory')
141
- return chunksOf(undefined);
142
- if (s.kind === 'symlink')
143
- return chunksOf(s.entry.linkTarget);
144
- const d = s.entry.data;
145
- return chunksOf(typeof d === 'function' ? await d() : d);
164
+ return { chunks: chunksOf(undefined), size: 0 };
165
+ if (s.kind === 'symlink') {
166
+ const target = toBytes(s.entry.linkTarget ?? '');
167
+ return { chunks: chunksOf(target), size: target.length };
168
+ }
169
+ const given = s.entry.data;
170
+ const raw = typeof given === 'function' ? await given() : given;
171
+ const data = typeof raw === 'string' ? toBytes(raw) : raw;
172
+ const own = data === undefined ? 0 : data instanceof Uint8Array ? data.length : undefined;
173
+ const size = s.entry.size;
174
+ if (size === undefined)
175
+ return { chunks: chunksOf(data), size: own };
176
+ if (own !== undefined && own !== size)
177
+ throw new PackError(`Entry ${JSON.stringify(s.name)} holds ${own} bytes, not the ${size} its size gives`);
178
+ return { chunks: sized(chunksOf(data), size, s.name), size };
146
179
  }
147
180
  /** Reads an entry's content into memory or a temporary file, counting its CRC-32. */
148
181
  async function spillOf(s, temp, threshold, check) {
182
+ return spillChunks((await contentOf(s)).chunks, temp, threshold, check);
183
+ }
184
+ /** Reads chunks into memory or a temporary file, counting their CRC-32. */
185
+ async function spillChunks(chunks, temp, threshold, check) {
149
186
  const spill = new io_1.SpillSink(temp, threshold);
150
187
  let crc = 0;
151
188
  try {
152
- for await (const c of checked(await contentOf(s), check)) {
189
+ for await (const c of checked(chunks, check)) {
153
190
  crc = (0, crc32_1.crc32)(c, crc);
154
191
  await spill.write(c);
155
192
  }
@@ -236,7 +273,8 @@ async function writeZipEntries(entries, sink, options, temp, check) {
236
273
  * second, hidden entry gets past a reader of the central directory.
237
274
  */
238
275
  async function streamZipEntry(out, s, info, level, check) {
239
- const it = checked(await contentOf(s), check)[Symbol.asyncIterator]();
276
+ const content = await contentOf(s);
277
+ const it = checked(content.chunks, check)[Symbol.asyncIterator]();
240
278
  let first;
241
279
  try {
242
280
  first = await it.next();
@@ -277,7 +315,9 @@ async function streamZipEntry(out, s, info, level, check) {
277
315
  }
278
316
  };
279
317
  Object.assign(info, { method: 8, versionNeeded: 20 });
280
- await out.streamEntry(info, (0, codecs_1.deflateRaw)(raw(), level), () => ({ crc, size }));
318
+ // zlib's deflateBound for any settings: deflate never grows data by more than this.
319
+ const zip64 = content.size !== undefined && content.size + Math.ceil(content.size / 8) + Math.ceil(content.size / 64) + 5 >= 0xffffffff;
320
+ await out.streamEntry(info, (0, codecs_1.deflateRaw)(raw(), level), () => ({ crc, size }), zip64);
281
321
  }
282
322
  // ---------------------------------------------------------------------------------------------------------------
283
323
  // tar
@@ -287,9 +327,25 @@ async function* tarChunks(entries, options, temp, check) {
287
327
  let written = 0;
288
328
  const pad = (n) => Buffer.alloc((tar_1.BLOCK - (n % tar_1.BLOCK)) % tar_1.BLOCK);
289
329
  for await (const s of entries) {
290
- const { spill } = s.kind === 'file' ? await spillOf(s, temp, threshold, check) : { spill: undefined };
330
+ let spill;
331
+ let direct;
332
+ let first;
333
+ let size = 0;
291
334
  try {
292
- const size = spill?.length ?? 0;
335
+ if (s.kind === 'file') {
336
+ const content = await contentOf(s);
337
+ if (content.size === undefined) {
338
+ ({ spill } = await spillChunks(content.chunks, temp, threshold, check));
339
+ size = spill.length;
340
+ }
341
+ else {
342
+ // The header can go first and the data straight after it. The first chunk is read before the header, so a
343
+ // stream that fails at once fails before its entry is begun, and a stream is open to close should the work stop.
344
+ size = content.size;
345
+ direct = checked(content.chunks, check)[Symbol.asyncIterator]();
346
+ first = await direct.next();
347
+ }
348
+ }
293
349
  const link = s.kind === 'symlink' ? (s.entry.linkTarget ?? '') : '';
294
350
  const mtime = Math.floor(s.mtime.getTime() / 1000);
295
351
  // What the ustar header cannot hold goes in a pax header before it.
@@ -344,8 +400,13 @@ async function* tarChunks(entries, options, temp, check) {
344
400
  });
345
401
  yield h;
346
402
  written += h.length;
347
- if (spill) {
403
+ if (spill)
348
404
  yield* checked(spill.read(), check);
405
+ if (direct && first) {
406
+ for (let r = first; !r.done; r = await direct.next())
407
+ yield r.value;
408
+ }
409
+ if (spill || direct) {
349
410
  const p = pad(size);
350
411
  if (p.length)
351
412
  yield p;
@@ -353,6 +414,7 @@ async function* tarChunks(entries, options, temp, check) {
353
414
  }
354
415
  }
355
416
  finally {
417
+ await direct?.return?.(undefined);
356
418
  await spill?.dispose();
357
419
  }
358
420
  }
@@ -476,6 +538,15 @@ function compressChunks(format, input, options, temp, now, check) {
476
538
  // The calls
477
539
  const ARCHIVE_FORMATS = new Set(['zip', 'tar', 'tar.gz', 'tar.bz2', 'tar.zst', 'tar.xz', '7z']);
478
540
  const TAR_COMPRESSION = { 'tar.gz': 'gzip', 'tar.bz2': 'bzip2', 'tar.zst': 'zstd', 'tar.xz': 'xz' };
541
+ /** Throws for a format `createArchive` does not write, and for `streaming` with any format but ZIP. */
542
+ function checkFormat(format, options) {
543
+ if (!ARCHIVE_FORMATS.has(format))
544
+ throw new PackError(`Unknown archive format ${String(format)}`);
545
+ // tar writes a file of known size as it arrives without the option, and needs the size of any other first. 7z writes
546
+ // its header from every entry after the data.
547
+ if (options.streaming && format !== 'zip')
548
+ throw new PackError(`streaming writes ZIP archives only, not ${format}`);
549
+ }
479
550
  /** Copies chunks to the sink, then closes it. On failure it aborts the sink, so a partial output never looks whole. */
480
551
  async function deliver(produce, sink) {
481
552
  try {
@@ -498,11 +569,7 @@ async function deliver(produce, sink) {
498
569
  * sink is aborted instead of closed and the error is thrown.
499
570
  */
500
571
  async function createArchive(format, entries, sink, options = {}) {
501
- if (!ARCHIVE_FORMATS.has(format))
502
- throw new PackError(`Unknown archive format ${String(format)}`);
503
- // tar needs each entry's size before its data, and 7z writes its header from every entry after the data.
504
- if (options.streaming && format !== 'zip')
505
- throw new PackError(`streaming writes ZIP archives only, not ${format}`);
572
+ checkFormat(format, options);
506
573
  const now = new Date();
507
574
  const temp = new io_1.TempDir(options.tempDir);
508
575
  const check = () => options.signal?.throwIfAborted();
@@ -563,6 +630,95 @@ async function createArchiveBytes(format, entries, options = {}) {
563
630
  await createArchive(format, entries, sink, options);
564
631
  return sink.result();
565
632
  }
633
+ /**
634
+ * `createArchiveStream` for a caller that finds its entries as it goes. An entry `createArchive` refuses, or data that
635
+ * fails, fails the archive: `finish` rejects with the error and `stream` is destroyed with it.
636
+ */
637
+ function createArchiveWriter(format, options = {}) {
638
+ checkFormat(format, options);
639
+ const queue = [];
640
+ let head = 0;
641
+ let closed;
642
+ let failure;
643
+ let wake;
644
+ const drop = () => {
645
+ queue.length = 0;
646
+ head = 0;
647
+ };
648
+ const stop = new AbortController();
649
+ const outer = options.signal;
650
+ const forward = () => stop.abort(outer?.reason);
651
+ if (outer?.aborted)
652
+ forward();
653
+ else
654
+ outer?.addEventListener('abort', forward, { once: true });
655
+ // The loop waits here for the next entry. The wait ends when the work stops, so a reader that goes away or an abort
656
+ // does not leave it waiting for an entry that never comes.
657
+ const nextEntry = (signal) => new Promise((resolve, reject) => {
658
+ const stopped = () => {
659
+ wake = undefined;
660
+ reject(signal.reason);
661
+ };
662
+ signal.addEventListener('abort', stopped, { once: true });
663
+ wake = () => {
664
+ signal.removeEventListener('abort', stopped);
665
+ wake = undefined;
666
+ resolve();
667
+ };
668
+ });
669
+ async function* entries(signal) {
670
+ for (;;) {
671
+ if (head < queue.length) {
672
+ const e = queue[head];
673
+ queue[head++] = undefined;
674
+ if (head === queue.length)
675
+ drop();
676
+ if (e)
677
+ yield e;
678
+ continue;
679
+ }
680
+ if (closed === 'finished')
681
+ return;
682
+ signal.throwIfAborted();
683
+ await nextEntry(signal);
684
+ }
685
+ }
686
+ let done = Promise.resolve();
687
+ const stream = readableOf((sink, signal) => {
688
+ done = createArchive(format, entries(signal), sink, { ...options, signal });
689
+ return done;
690
+ }, stop.signal);
691
+ done.then(() => outer?.removeEventListener('abort', forward), error => {
692
+ failure ??= { error };
693
+ drop();
694
+ outer?.removeEventListener('abort', forward);
695
+ });
696
+ return {
697
+ stream,
698
+ add(entry) {
699
+ if (closed)
700
+ throw new PackError(`An entry was added after the archive was ${closed}`);
701
+ if (failure)
702
+ throw new PackError('An entry was added after the archive failed', { cause: failure.error });
703
+ queue.push(entry);
704
+ wake?.();
705
+ },
706
+ finish() {
707
+ if (!closed) {
708
+ closed = 'finished';
709
+ wake?.();
710
+ }
711
+ return done;
712
+ },
713
+ abort(reason) {
714
+ if (closed === 'aborted')
715
+ return;
716
+ closed = 'aborted';
717
+ drop();
718
+ stop.abort(reason);
719
+ },
720
+ };
721
+ }
566
722
  /** Compresses `data` as one stream of `format` to `sink`, then closes it. */
567
723
  async function compressTo(format, data, sink, options = {}) {
568
724
  const temp = new io_1.TempDir(options.tempDir);
@@ -37,13 +37,17 @@ export declare class ZipOutput {
37
37
  /**
38
38
  * Writes one entry whose sizes are known only once its data has gone out: a local header with the data descriptor
39
39
  * flag and no CRC or sizes, then `chunks`, the compressed data, then a data descriptor with the CRC and the sizes.
40
- * `totals` gives the CRC and size of the uncompressed data once `chunks` has ended. An entry of 4 GiB or more gets
41
- * eight-byte sizes in its descriptor and a ZIP64 field in the central directory, as archiver writes one.
40
+ * `totals` gives the CRC and size of the uncompressed data once `chunks` has ended.
41
+ *
42
+ * With `zip64`, the local header has a ZIP64 field, which tells a reader the descriptor's sizes are eight bytes. Its
43
+ * sizes are 0xFFFFFFFF and the field's are zero, as Python's zipfile writes a streamed entry. Without it, an entry of
44
+ * 4 GiB or more still gets eight-byte sizes in its descriptor, as archiver writes one. Either way, the central
45
+ * directory has a ZIP64 field only when a size or the offset needs one.
42
46
  */
43
47
  streamEntry(info: Written, chunks: AsyncIterable<Uint8Array>, totals: () => {
44
48
  crc: number;
45
49
  size: number;
46
- }): Promise<void>;
50
+ }, zip64?: boolean): Promise<void>;
47
51
  /** Writes the central directory and the end records, with the archive's comment. */
48
52
  end(comment: Uint8Array): Promise<void>;
49
53
  }
@@ -89,26 +89,33 @@ class ZipOutput {
89
89
  /**
90
90
  * Writes one entry whose sizes are known only once its data has gone out: a local header with the data descriptor
91
91
  * flag and no CRC or sizes, then `chunks`, the compressed data, then a data descriptor with the CRC and the sizes.
92
- * `totals` gives the CRC and size of the uncompressed data once `chunks` has ended. An entry of 4 GiB or more gets
93
- * eight-byte sizes in its descriptor and a ZIP64 field in the central directory, as archiver writes one.
92
+ * `totals` gives the CRC and size of the uncompressed data once `chunks` has ended.
93
+ *
94
+ * With `zip64`, the local header has a ZIP64 field, which tells a reader the descriptor's sizes are eight bytes. Its
95
+ * sizes are 0xFFFFFFFF and the field's are zero, as Python's zipfile writes a streamed entry. Without it, an entry of
96
+ * 4 GiB or more still gets eight-byte sizes in its descriptor, as archiver writes one. Either way, the central
97
+ * directory has a ZIP64 field only when a size or the offset needs one.
94
98
  */
95
- async streamEntry(info, chunks, totals) {
99
+ async streamEntry(info, chunks, totals, zip64 = false) {
96
100
  const flags = info.flags | 0x0008;
101
+ const versionNeeded = zip64 ? Math.max(45, info.versionNeeded) : info.versionNeeded;
97
102
  const localOffset = this.offset;
98
103
  const local = new Builder()
99
104
  .u32(0x04034b50)
100
- .u16(info.versionNeeded)
105
+ .u16(versionNeeded)
101
106
  .u16(flags)
102
107
  .u16(info.method)
103
108
  .u16(info.time)
104
109
  .u16(info.date)
105
110
  .u32(0)
106
- .u32(0)
107
- .u32(0)
111
+ .u32(zip64 ? MAX32 : 0)
112
+ .u32(zip64 ? MAX32 : 0)
108
113
  .u16(info.name.length)
109
- .u16(info.extras.length)
110
- .bytes(info.name)
111
- .bytes(info.extras);
114
+ .u16(info.extras.length + (zip64 ? 20 : 0))
115
+ .bytes(info.name);
116
+ if (zip64)
117
+ local.u16(0x0001).u16(16).u64(0).u64(0);
118
+ local.bytes(info.extras);
112
119
  await this.put(local.build());
113
120
  let compressedSize = 0;
114
121
  for await (const c of chunks) {
@@ -117,12 +124,12 @@ class ZipOutput {
117
124
  }
118
125
  const { crc, size } = totals();
119
126
  const descriptor = new Builder().u32(0x08074b50).u32(crc);
120
- if (size >= MAX32 || compressedSize >= MAX32)
127
+ if (zip64 || size >= MAX32 || compressedSize >= MAX32)
121
128
  descriptor.u64(compressedSize).u64(size);
122
129
  else
123
130
  descriptor.u32(compressedSize).u32(size);
124
131
  await this.put(descriptor.build());
125
- this.central.push(centralRecord({ ...info, flags, crc, size, compressedSize }, localOffset));
132
+ this.central.push(centralRecord({ ...info, versionNeeded, flags, crc, size, compressedSize }, localOffset));
126
133
  }
127
134
  /** Writes the central directory and the end records, with the archive's comment. */
128
135
  async end(comment) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/archive-defuse",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Sanitize zip, tar, gzip, bzip2, zstd, Brotli, cab, 7z, xz and lzma archives. Detects path traversal, links out of the tree, device files, duplicate names, overlapping entries, hidden and prepended data, encrypted entries and compression bombs, hands every entry to file plugins, scores the risk, and writes a defused archive in the same format with no runtime dependencies.",
5
5
  "license": "MIT AND Apache-2.0",
6
6
  "author": "patrickdk77",