@patrickdk77/archive-defuse 0.1.1 → 0.1.3

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/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.3",
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",