@patrickdk77/archive-defuse 0.1.0 → 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
@@ -247,6 +247,35 @@ A finding from another package scores at its `weight`. An archive's findings car
247
247
 
248
248
  archive-defuse follows the contract of pdf-defuse. `ContainedFilePlugin`, `PluginContext`, `ContainedFile`, `ContainedFileResult` and `DefuseFinding` have pdf-defuse's shapes, and `context.depth` is the depth of the file itself, with the upload at 0. `archivePlugin(options)` is the plugin the router builds, and the router puts itself in `filePlugins`, so an entry of any format comes back to it. The same `password` list goes to every package.
249
249
 
250
+ ### Detection
251
+
252
+ `detectArchive(bytes, options)` and `detectArchiveSource(source, options)` tell a router whether archive-defuse reads a file, and in which format. They return `{ mimeType, extension, offset }`, or undefined. The answer comes from the same format selection the inspection runs, so routing and reading cannot disagree. The content decides, apart from Brotli below. `options.name` and `options.declaredType` change nothing else, so a .docx comes back as a ZIP, and the router asks office-defuse before it takes that answer.
253
+
254
+ | Format | `mimeType` | `extension` |
255
+ |---|---|---|
256
+ | ZIP and ZIPX | application/zip | zip |
257
+ | tar | application/x-tar | tar |
258
+ | gzip | application/gzip | gz |
259
+ | bzip2 | application/x-bzip2 | bz2 |
260
+ | zstd | application/zstd | zst |
261
+ | Brotli | application/x-brotli | br |
262
+ | cabinet | application/vnd.ms-cab-compressed | cab |
263
+ | 7z | application/x-7z-compressed | 7z |
264
+ | xz | application/x-xz | xz |
265
+ | .lzma | application/x-lzma | lzma |
266
+
267
+ ZIPX has no format of its own here. The ZIP reader reads it, so it comes back as a ZIP.
268
+
269
+ `offset` is where the archive's data starts. It is 0 when a signature starts the file. For a ZIP found from its end record, it is where the ZIP reader finds the first entry, the same number STRUCTURE/LEADING_BYTES reports, or 0 when the reader cannot open the archive. For a 7z archive after a self-extracting program, it is the signature header's offset.
270
+
271
+ Three claims rest on no signature, and those answers carry `heuristic`, which says what the claim rests on. An .lzma file passes liblzma's header test. A Brotli file decodes as one stream that ends with the file, or past 32 MiB without an error. A file of zeros, at least 1024 bytes long and a multiple of 512, is an empty tar. Other runs of zeros, from 14 bytes, pass the .lzma test instead.
272
+
273
+ A file the selection picks and the reader then refuses, such as a truncated ZIP, still counts, and the inspection rejects it with its own finding. RAR is detected only to be rejected, so detection does not claim it.
274
+
275
+ Brotli has no signature, and a file of one or two bytes can decode as a Brotli stream. A file holding just `5` is an empty one. So detection claims Brotli only when `options.name` ends in `.br` or `options.declaredType` is application/x-brotli, and the whole file decodes. `archivePlugin` accepts exactly what detection claims, with the file's own name and declared type.
276
+
277
+ `detectArchiveSource` leaves the source open. It passes read errors on, and throws with the `DEFUSE_LIMIT` code once `options.deadline` passes. Detection uses no temporary files and ignores `tempDir` and `memoryThreshold`, which the family's options carry for other packages.
278
+
250
279
  ## Making and reading archives
251
280
 
252
281
  archive-defuse also makes archives and reads them, with no defusing involved, for code that would otherwise use archiver, adm-zip or tar-stream.
@@ -274,9 +303,33 @@ const result = JSON.parse(await zip.text('result.json'));
274
303
 
275
304
  - `name`, with "/" between folders. A name that ends in "/" is a folder.
276
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.
277
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.
278
308
 
279
- 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.
310
+
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.
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.
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
+ ```
280
333
 
281
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.
282
335
 
@@ -0,0 +1,10 @@
1
+ import type { ByteSource, DetectOptions, FormatDetection } from './types';
2
+ /**
3
+ * Whether archive-defuse reads `source`, and in which format: what the inspection's own format selection picks, from
4
+ * the content alone, except that Brotli, which has no signature, counts only when the name or the declared type says
5
+ * Brotli. A format detected only to be rejected, such as RAR, is not claimed. A ZIP archive found from its end record starts where the ZIP reader finds its first entry, or at
6
+ * 0 when the reader cannot open it. The source stays open.
7
+ */
8
+ export declare function detectArchiveSource(source: ByteSource, options?: DetectOptions): Promise<FormatDetection | undefined>;
9
+ /** `detectArchiveSource` over bytes in memory. */
10
+ export declare function detectArchive(bytes: Uint8Array, options?: DetectOptions): Promise<FormatDetection | undefined>;
package/dist/detect.js ADDED
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.detectArchiveSource = detectArchiveSource;
4
+ exports.detectArchive = detectArchive;
5
+ const engine_1 = require("./engine");
6
+ const errors_1 = require("./errors");
7
+ const formats_1 = require("./formats");
8
+ const io_1 = require("./io");
9
+ const sniff_1 = require("./sniff");
10
+ const zip_1 = require("./zip");
11
+ /** Each format's MIME type and extension, the names sniff.ts gives them. */
12
+ const TYPES = {
13
+ zip: { mimeType: 'application/zip', extension: 'zip' },
14
+ tar: { mimeType: 'application/x-tar', extension: 'tar' },
15
+ gzip: { mimeType: 'application/gzip', extension: 'gz' },
16
+ bzip2: { mimeType: 'application/x-bzip2', extension: 'bz2' },
17
+ zstd: { mimeType: 'application/zstd', extension: 'zst' },
18
+ brotli: { mimeType: 'application/x-brotli', extension: 'br' },
19
+ cab: { mimeType: 'application/vnd.ms-cab-compressed', extension: 'cab' },
20
+ '7z': { mimeType: 'application/x-7z-compressed', extension: '7z' },
21
+ xz: { mimeType: 'application/x-xz', extension: 'xz' },
22
+ lzma: { mimeType: 'application/x-lzma', extension: 'lzma' },
23
+ };
24
+ /**
25
+ * `source` without its close(), checking the deadline before every call and keeping the first error any call throws.
26
+ * The Brotli test catches every error, so the kept one is how a failed read or the deadline still gets out.
27
+ */
28
+ function watch(source, deadline) {
29
+ const failed = {};
30
+ const keep = async (op) => {
31
+ try {
32
+ (0, errors_1.checkTime)(deadline);
33
+ return await op();
34
+ }
35
+ catch (e) {
36
+ failed.error ??= e;
37
+ throw e;
38
+ }
39
+ };
40
+ return { source: { size: () => keep(() => source.size()), read: (offset, length) => keep(() => source.read(offset, length)) }, failed };
41
+ }
42
+ const BROTLI_TYPES = new Set(['application/x-brotli', 'application/brotli']);
43
+ const saysBrotli = (o) => (0, sniff_1.typeFromName)(o.name) === 'application/x-brotli' || BROTLI_TYPES.has((o.declaredType ?? '').toLowerCase().split(';')[0].trim());
44
+ /**
45
+ * Whether archive-defuse reads `source`, and in which format: what the inspection's own format selection picks, from
46
+ * the content alone, except that Brotli, which has no signature, counts only when the name or the declared type says
47
+ * Brotli. A format detected only to be rejected, such as RAR, is not claimed. A ZIP archive found from its end record starts where the ZIP reader finds its first entry, or at
48
+ * 0 when the reader cannot open it. The source stays open.
49
+ */
50
+ async function detectArchiveSource(source, options = {}) {
51
+ const watched = watch(source, options.deadline);
52
+ const pass = (e) => {
53
+ if (watched.failed.error !== undefined)
54
+ throw watched.failed.error;
55
+ if ((0, errors_1.stopsTree)(e) || (0, errors_1.isIoError)(e))
56
+ throw e;
57
+ };
58
+ let picked;
59
+ try {
60
+ (0, errors_1.checkTime)(options.deadline);
61
+ picked = await (0, formats_1.select)(watched.source);
62
+ pass(undefined);
63
+ }
64
+ catch (e) {
65
+ pass(e);
66
+ // Any other failure, as in an inspection, means archive-defuse does not read the file.
67
+ return undefined;
68
+ }
69
+ if (picked === undefined)
70
+ return undefined;
71
+ // Brotli has no signature, and a one-byte file such as "7" decodes as an empty stream. Without a name or declared
72
+ // type that says Brotli, archive-defuse cannot know the file is one, so it does not claim it.
73
+ if (picked.format === 'brotli' && !saysBrotli(options))
74
+ return undefined;
75
+ let offset = picked.offset;
76
+ if (offset === undefined) {
77
+ try {
78
+ offset = (await zip_1.ZipArchive.open(watched.source, engine_1.DEFAULT_LIMITS, options.deadline)).start;
79
+ pass(undefined);
80
+ }
81
+ catch (e) {
82
+ pass(e);
83
+ // The selection picked ZIP, so the file is one, which the inspection rejects with its own finding.
84
+ offset = 0;
85
+ }
86
+ }
87
+ const found = { ...TYPES[picked.format], offset };
88
+ if (picked.heuristic !== undefined)
89
+ found.heuristic = picked.heuristic;
90
+ return found;
91
+ }
92
+ /** `detectArchiveSource` over bytes in memory. */
93
+ function detectArchive(bytes, options = {}) {
94
+ return detectArchiveSource((0, io_1.bufferSource)(bytes), options);
95
+ }
package/dist/formats.d.ts CHANGED
@@ -2,10 +2,20 @@ import type { TempDir } from './io';
2
2
  import type { Archive, Entry } from './model';
3
3
  import type { Limits } from './stream-meter';
4
4
  import { type ArchiveFormat, type ByteSource } from './types';
5
+ /** The format `select` picks, where its data starts, and what a pick without a signature at the start rests on. */
6
+ export interface Selection {
7
+ format: ArchiveFormat;
8
+ /** Where the format's data starts. Absent for a ZIP archive found from its end record, whose start the ZIP reader finds. */
9
+ offset?: number;
10
+ /** Set when no signature picked the format, and says what did. */
11
+ heuristic?: string;
12
+ }
5
13
  /**
6
14
  * The format the content has, from its first bytes. A file that starts with no signature of ours can still be a ZIP
7
15
  * archive with data in front, which its end record shows, or Brotli, which has no signature and is tried last.
8
16
  */
17
+ export declare function select(source: ByteSource): Promise<Selection | undefined>;
18
+ /** The format `select` picks. */
9
19
  export declare function detect(source: ByteSource): Promise<ArchiveFormat | undefined>;
10
20
  /** Opens the archive in the format `detect` found. A file in no format of ours throws the UNPARSEABLE refusal. */
11
21
  export declare function openArchive(source: ByteSource, limits: Limits, deadline: number | undefined, passwords: string[], temp?: {
package/dist/formats.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.select = select;
3
4
  exports.detect = detect;
4
5
  exports.openArchive = openArchive;
5
6
  const cab_1 = require("./cab");
@@ -24,42 +25,50 @@ function otherFormat(b) {
24
25
  * The format the content has, from its first bytes. A file that starts with no signature of ours can still be a ZIP
25
26
  * archive with data in front, which its end record shows, or Brotli, which has no signature and is tried last.
26
27
  */
27
- async function detect(source) {
28
+ async function select(source) {
28
29
  const size = await source.size();
29
30
  const head = await source.read(0, Math.min(1024, size));
31
+ const atStart = (format) => ({ format, offset: 0 });
30
32
  if (isZipStart(head))
31
- return 'zip';
33
+ return atStart('zip');
32
34
  if (startsWith(head, lzma_sniff_1.XZ_MAGIC))
33
- return 'xz';
35
+ return atStart('xz');
34
36
  if (startsWith(head, lzma_sniff_1.SEVENZIP_MAGIC))
35
- return '7z';
37
+ return atStart('7z');
36
38
  if ((0, single_1.isGzip)(head))
37
- return 'gzip';
39
+ return atStart('gzip');
38
40
  if ((0, single_1.isBzip2)(head))
39
- return 'bzip2';
41
+ return atStart('bzip2');
40
42
  if ((0, zstd_1.isZstdMagic)(head))
41
- return 'zstd';
43
+ return atStart('zstd');
42
44
  if ((0, cab_1.isCab)(head))
43
- return 'cab';
45
+ return atStart('cab');
44
46
  if ((0, tar_1.isTar)(head))
45
- return 'tar';
47
+ return atStart('tar');
46
48
  // An archive with no entries is its two end blocks, and a tar writer pads it with more zeros.
47
- if (size >= 1024 && size % 512 === 0 && head.every(b => b === 0))
48
- return 'tar';
49
+ if (size >= 1024 && size % 512 === 0 && head.every(b => b === 0)) {
50
+ return { format: 'tar', offset: 0, heuristic: 'no tar header: the file is zeros, at least 1024 bytes and a multiple of 512, as an empty tar is' };
51
+ }
49
52
  if (otherFormat(head) !== undefined)
50
53
  return undefined;
51
54
  // An .lzma header has no magic, and 14 zero bytes pass liblzma's own test, so a ZIP found from its end comes first.
52
55
  if (await (0, zip_1.hasZipEnd)(source))
53
- return 'zip';
56
+ return { format: 'zip' };
54
57
  if ((0, lzma_sniff_1.looksLikeLzma)(head))
55
- return 'lzma';
58
+ return { format: 'lzma', offset: 0, heuristic: "no signature: the 13-byte header and the first data byte pass liblzma's test for an .lzma file" };
56
59
  // A 7z archive after other data, such as the program of a self-extracting archive.
57
- if ((0, lzma_sniff_1.detectLzmaFormat)(await source.read(0, Math.min(size, lzma_sniff_1.SEVENZIP_SEARCH)))?.format === '7z')
58
- return '7z';
59
- if (await (0, single_1.looksLikeBrotli)(source))
60
- return 'brotli';
60
+ const sfx = (0, lzma_sniff_1.detectLzmaFormat)(await source.read(0, Math.min(size, lzma_sniff_1.SEVENZIP_SEARCH)));
61
+ if (sfx?.format === '7z')
62
+ return { format: '7z', offset: sfx.offset };
63
+ if (await (0, single_1.looksLikeBrotli)(source)) {
64
+ return { format: 'brotli', offset: 0, heuristic: 'no signature: the file decodes as one Brotli stream that ends where the file does, or past 32 MiB without an error' };
65
+ }
61
66
  return undefined;
62
67
  }
68
+ /** The format `select` picks. */
69
+ async function detect(source) {
70
+ return (await select(source))?.format;
71
+ }
63
72
  /** Opens the archive in the format `detect` found. A file in no format of ours throws the UNPARSEABLE refusal. */
64
73
  async function openArchive(source, limits, deadline, passwords, temp) {
65
74
  const format = await detect(source);
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ export { detectArchive, detectArchiveSource } from './detect';
1
2
  export { disarmArchive, disarmArchiveSource, inspectArchive, inspectArchiveSource } from './engine';
2
3
  export { allFindingSpecs } from './findings';
3
4
  export { bufferSink, bufferSource, fileSink, fileSource, writableSink } from './io';
4
- 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';
5
6
  export { archivePlugin, passThrough } from './plugins';
6
7
  export { COMBINATIONS, DEFAULT_BANDS, scoreFindings } from './score';
7
8
  export * from './types';
package/dist/index.js CHANGED
@@ -14,7 +14,10 @@ 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 = 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
+ var detect_1 = require("./detect");
19
+ Object.defineProperty(exports, "detectArchive", { enumerable: true, get: function () { return detect_1.detectArchive; } });
20
+ Object.defineProperty(exports, "detectArchiveSource", { enumerable: true, get: function () { return detect_1.detectArchiveSource; } });
18
21
  var engine_1 = require("./engine");
19
22
  Object.defineProperty(exports, "disarmArchive", { enumerable: true, get: function () { return engine_1.disarmArchive; } });
20
23
  Object.defineProperty(exports, "disarmArchiveSource", { enumerable: true, get: function () { return engine_1.disarmArchiveSource; } });
@@ -35,6 +38,7 @@ Object.defineProperty(exports, "compressTo", { enumerable: true, get: function (
35
38
  Object.defineProperty(exports, "createArchive", { enumerable: true, get: function () { return pack_1.createArchive; } });
36
39
  Object.defineProperty(exports, "createArchiveBytes", { enumerable: true, get: function () { return pack_1.createArchiveBytes; } });
37
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; } });
38
42
  Object.defineProperty(exports, "PackError", { enumerable: true, get: function () { return pack_1.PackError; } });
39
43
  var plugins_1 = require("./plugins");
40
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 { 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,12 +51,23 @@ 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. */
52
60
  signal?: AbortSignal;
61
+ /**
62
+ * ZIP only: write each file as its data arrives, deflated on the way, with its CRC and sizes in a data descriptor
63
+ * after the data. Nothing goes to a temporary file. An entry with `store: true` is held in memory, since a stored
64
+ * entry keeps its sizes in its header. `ratioLimit` does not apply, since an entry is written before its ratio is
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.
69
+ */
70
+ streaming?: boolean;
53
71
  }
54
72
  /** Thrown for an entry this package will not write, before anything of the entry is written. */
55
73
  export declare class PackError extends Error {
@@ -64,6 +82,31 @@ export declare function createArchive(format: PackFormat, entries: Iterable<Pack
64
82
  export declare function createArchiveStream(format: PackFormat, entries: Iterable<PackEntry> | AsyncIterable<PackEntry>, options?: PackOptions): Readable;
65
83
  /** `createArchive` into memory. */
66
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;
67
110
  /** Compresses `data` as one stream of `format` to `sink`, then closes it. */
68
111
  export declare function compressTo(format: CompressFormat, data: PackData, sink: ByteSink, options?: PackOptions): Promise<void>;
69
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;
@@ -28,6 +29,8 @@ const enc = new TextEncoder();
28
29
  /** The reading default of `limits.compressionRatio`. */
29
30
  const DEFAULT_RATIO = 1000;
30
31
  const toBytes = (c) => (typeof c === 'string' ? enc.encode(c) : c);
32
+ /** Bytes that small chunks of an entry are gathered into before they go on. */
33
+ const BATCH = 64 * 1024;
31
34
  /** `chunks`, with `check` before each one, so an aborted signal stops a long read. */
32
35
  async function* checked(chunks, check) {
33
36
  for await (const c of chunks) {
@@ -45,11 +48,28 @@ async function* chunksOf(data) {
45
48
  yield b;
46
49
  return;
47
50
  }
51
+ // Small chunks, such as one row of a sheet each, go to the compressor in pieces of BATCH bytes. zlib does the same
52
+ // work either way, and each write costs as much as a large one.
53
+ let pending = [];
54
+ let held = 0;
48
55
  for await (const c of data) {
49
56
  const b = toBytes(c);
50
- if (b.length)
57
+ if (!b.length)
58
+ continue;
59
+ if (held === 0 && b.length >= BATCH) {
51
60
  yield b;
61
+ continue;
62
+ }
63
+ pending.push(b);
64
+ held += b.length;
65
+ if (held >= BATCH) {
66
+ yield Buffer.concat(pending, held);
67
+ pending = [];
68
+ held = 0;
69
+ }
52
70
  }
71
+ if (held)
72
+ yield Buffer.concat(pending, held);
53
73
  }
54
74
  /** Why `createArchive` refuses a name, for each name rule of the inspection that changes a name. */
55
75
  const REFUSED = {
@@ -100,6 +120,12 @@ function settle(entry, options, now) {
100
120
  const kind = entry.kind ?? (raw.endsWith('/') ? 'directory' : entry.linkTarget !== undefined ? 'symlink' : 'file');
101
121
  if (kind === 'symlink' && entry.linkTarget === undefined)
102
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
+ }
103
129
  const name = kind === 'directory' ? (raw.endsWith('/') ? raw : `${raw}/`) : raw.replace(/\/+$/, '');
104
130
  for (const detail of (0, rules_1.nameDetails)(name, true)) {
105
131
  const why = REFUSED[detail];
@@ -116,21 +142,51 @@ function settle(entry, options, now) {
116
142
  }
117
143
  return { name, kind, entry, mode: (entry.mode ?? (kind === 'directory' ? 0o755 : kind === 'symlink' ? 0o777 : 0o644)) & 0o7777, mtime: entry.mtime ?? options.mtime ?? now };
118
144
  }
119
- /** 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
+ */
120
162
  async function contentOf(s) {
121
163
  if (s.kind === 'directory')
122
- return chunksOf(undefined);
123
- if (s.kind === 'symlink')
124
- return chunksOf(s.entry.linkTarget);
125
- const d = s.entry.data;
126
- 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 };
127
179
  }
128
180
  /** Reads an entry's content into memory or a temporary file, counting its CRC-32. */
129
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) {
130
186
  const spill = new io_1.SpillSink(temp, threshold);
131
187
  let crc = 0;
132
188
  try {
133
- for await (const c of checked(await contentOf(s), check)) {
189
+ for await (const c of checked(chunks, check)) {
134
190
  crc = (0, crc32_1.crc32)(c, crc);
135
191
  await spill.write(c);
136
192
  }
@@ -180,6 +236,10 @@ async function writeZipEntries(entries, sink, options, temp, check) {
180
236
  await out.entry(info, () => chunksOf(undefined), s.name);
181
237
  continue;
182
238
  }
239
+ if (options.streaming) {
240
+ await streamZipEntry(out, s, info, level, check);
241
+ continue;
242
+ }
183
243
  // The content is read once, into memory or a temporary file, and deflated from there, so it can be stored instead
184
244
  // when deflate gains nothing or passes the ratio limit.
185
245
  const { spill: raw, crc } = await spillOf(s, temp, threshold, check);
@@ -206,6 +266,59 @@ async function writeZipEntries(entries, sink, options, temp, check) {
206
266
  }
207
267
  await out.end(enc.encode(options.comment ?? ''));
208
268
  }
269
+ /**
270
+ * Writes one entry of a streaming ZIP archive. A file is deflated as its data arrives and gets a data descriptor; at
271
+ * level 0, deflate writes stored blocks. An empty file is stored with its sizes. A link's target and a file the caller
272
+ * stores are held in memory, so their headers carry their sizes, since a stored entry with a data descriptor is how a
273
+ * second, hidden entry gets past a reader of the central directory.
274
+ */
275
+ async function streamZipEntry(out, s, info, level, check) {
276
+ const content = await contentOf(s);
277
+ const it = checked(content.chunks, check)[Symbol.asyncIterator]();
278
+ let first;
279
+ try {
280
+ first = await it.next();
281
+ }
282
+ catch (e) {
283
+ await it.return?.(undefined);
284
+ throw e;
285
+ }
286
+ if (s.kind === 'symlink' || s.entry.store === true || first.done) {
287
+ const parts = [];
288
+ let crc = 0;
289
+ try {
290
+ for (let r = first; !r.done; r = await it.next()) {
291
+ crc = (0, crc32_1.crc32)(r.value, crc);
292
+ parts.push(r.value);
293
+ }
294
+ }
295
+ finally {
296
+ await it.return?.(undefined);
297
+ }
298
+ const data = Buffer.concat(parts);
299
+ Object.assign(info, { method: 0, versionNeeded: 10, crc, size: data.length, compressedSize: data.length });
300
+ await out.entry(info, () => chunksOf(data), s.name);
301
+ return;
302
+ }
303
+ let crc = 0;
304
+ let size = 0;
305
+ const raw = async function* () {
306
+ try {
307
+ for (let r = first; !r.done; r = await it.next()) {
308
+ crc = (0, crc32_1.crc32)(r.value, crc);
309
+ size += r.value.length;
310
+ yield r.value;
311
+ }
312
+ }
313
+ finally {
314
+ await it.return?.(undefined);
315
+ }
316
+ };
317
+ Object.assign(info, { method: 8, versionNeeded: 20 });
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);
321
+ }
209
322
  // ---------------------------------------------------------------------------------------------------------------
210
323
  // tar
211
324
  const TAR_TYPES = { file: '0', directory: '5', symlink: '2' };
@@ -214,9 +327,25 @@ async function* tarChunks(entries, options, temp, check) {
214
327
  let written = 0;
215
328
  const pad = (n) => Buffer.alloc((tar_1.BLOCK - (n % tar_1.BLOCK)) % tar_1.BLOCK);
216
329
  for await (const s of entries) {
217
- 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;
218
334
  try {
219
- 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
+ }
220
349
  const link = s.kind === 'symlink' ? (s.entry.linkTarget ?? '') : '';
221
350
  const mtime = Math.floor(s.mtime.getTime() / 1000);
222
351
  // What the ustar header cannot hold goes in a pax header before it.
@@ -271,8 +400,13 @@ async function* tarChunks(entries, options, temp, check) {
271
400
  });
272
401
  yield h;
273
402
  written += h.length;
274
- if (spill) {
403
+ if (spill)
275
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) {
276
410
  const p = pad(size);
277
411
  if (p.length)
278
412
  yield p;
@@ -280,6 +414,7 @@ async function* tarChunks(entries, options, temp, check) {
280
414
  }
281
415
  }
282
416
  finally {
417
+ await direct?.return?.(undefined);
283
418
  await spill?.dispose();
284
419
  }
285
420
  }
@@ -403,6 +538,15 @@ function compressChunks(format, input, options, temp, now, check) {
403
538
  // The calls
404
539
  const ARCHIVE_FORMATS = new Set(['zip', 'tar', 'tar.gz', 'tar.bz2', 'tar.zst', 'tar.xz', '7z']);
405
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
+ }
406
550
  /** Copies chunks to the sink, then closes it. On failure it aborts the sink, so a partial output never looks whole. */
407
551
  async function deliver(produce, sink) {
408
552
  try {
@@ -425,8 +569,7 @@ async function deliver(produce, sink) {
425
569
  * sink is aborted instead of closed and the error is thrown.
426
570
  */
427
571
  async function createArchive(format, entries, sink, options = {}) {
428
- if (!ARCHIVE_FORMATS.has(format))
429
- throw new PackError(`Unknown archive format ${String(format)}`);
572
+ checkFormat(format, options);
430
573
  const now = new Date();
431
574
  const temp = new io_1.TempDir(options.tempDir);
432
575
  const check = () => options.signal?.throwIfAborted();
@@ -487,6 +630,95 @@ async function createArchiveBytes(format, entries, options = {}) {
487
630
  await createArchive(format, entries, sink, options);
488
631
  return sink.result();
489
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
+ }
490
722
  /** Compresses `data` as one stream of `format` to `sink`, then closes it. */
491
723
  async function compressTo(format, data, sink, options = {}) {
492
724
  const temp = new io_1.TempDir(options.tempDir);
package/dist/plugins.js CHANGED
@@ -2,8 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.passThrough = passThrough;
4
4
  exports.archivePlugin = archivePlugin;
5
+ const detect_1 = require("./detect");
5
6
  const engine_1 = require("./engine");
6
- const formats_1 = require("./formats");
7
7
  const score_1 = require("./score");
8
8
  const sniff_1 = require("./sniff");
9
9
  /**
@@ -36,7 +36,8 @@ function archivePlugin(options) {
36
36
  return {
37
37
  kind: 'file',
38
38
  name: 'archive',
39
- accepts: async (f) => (await (0, formats_1.detect)(f.source)) !== undefined,
39
+ // It takes what detectArchive claims, which is what the inspection reads, Brotli included: the content decides.
40
+ accepts: async (f) => (await (0, detect_1.detectArchiveSource)(f.source, { name: f.name, declaredType: f.declaredType })) !== undefined,
40
41
  async process(file, sink, context) {
41
42
  const run = options ?? context[engine_1.RUN_OPTIONS] ?? {};
42
43
  const r = await (0, engine_1.disarmAtDepth)(file.source, sink, run, context.depth, context.deadline);
package/dist/types.d.ts CHANGED
@@ -205,6 +205,33 @@ export interface ArchiveLimits {
205
205
  /** Milliseconds for the whole run. Not enforced unless set. */
206
206
  timeMs?: number;
207
207
  }
208
+ /**
209
+ * A file's format as a package of the family finds it, among the formats that package reads. The family's router asks
210
+ * each package and routes the file by the answers.
211
+ */
212
+ export interface FormatDetection {
213
+ /** The format's MIME type, such as application/zip. */
214
+ mimeType: string;
215
+ /** The format's usual extension, lowercase and without the dot. */
216
+ extension: string;
217
+ /** Where the format starts in the file: 0, or the offset of its data when other data comes first. */
218
+ offset: number;
219
+ /** Set when the match rests on more than the content, and says what it rests on. */
220
+ heuristic?: string;
221
+ }
222
+ /** What a detect function may know of a file besides its content. */
223
+ export interface DetectOptions {
224
+ /** The file's name, as the upload or the container that holds it gives it. */
225
+ name?: string;
226
+ /** The MIME type the upload or the container declares. */
227
+ declaredType?: string;
228
+ /** The Date.now() value detection must finish by, as `PluginContext.deadline`. */
229
+ deadline?: number;
230
+ /** Folder for temporary files. Defaults to `os.tmpdir()`. */
231
+ tempDir?: string;
232
+ /** Bytes held in memory before a temporary file takes over. */
233
+ memoryThreshold?: number;
234
+ }
208
235
  /** What a contained file plugin learns of the run, with nothing format-specific, so any package of the family can make one. */
209
236
  export interface PluginContext {
210
237
  /** The depth of the file itself: 0 for the upload, 1 for a file inside it, and so on, whatever the formats. */
@@ -34,6 +34,20 @@ export declare class ZipOutput {
34
34
  put(b: Uint8Array): Promise<void>;
35
35
  /** Writes one entry: its local header, then `chunks`, which must hold exactly `info.compressedSize` bytes. */
36
36
  entry(info: Written, chunks: () => AsyncIterable<Uint8Array>, label: string): Promise<void>;
37
+ /**
38
+ * Writes one entry whose sizes are known only once its data has gone out: a local header with the data descriptor
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.
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.
46
+ */
47
+ streamEntry(info: Written, chunks: AsyncIterable<Uint8Array>, totals: () => {
48
+ crc: number;
49
+ size: number;
50
+ }, zip64?: boolean): Promise<void>;
37
51
  /** Writes the central directory and the end records, with the archive's comment. */
38
52
  end(comment: Uint8Array): Promise<void>;
39
53
  }
@@ -84,39 +84,52 @@ class ZipOutput {
84
84
  }
85
85
  if (written !== info.compressedSize)
86
86
  throw new Error(`Entry ${label} has ${written} compressed bytes, not ${info.compressedSize}`);
87
- const far = localOffset >= MAX32;
88
- const z64 = new Builder();
89
- if (info.size >= MAX32)
90
- z64.u64(info.size);
91
- if (info.compressedSize >= MAX32)
92
- z64.u64(info.compressedSize);
93
- if (far)
94
- z64.u64(localOffset);
95
- const z64Bytes = z64.build();
96
- const needed = z64Bytes.length ? Math.max(45, info.versionNeeded) : info.versionNeeded;
97
- const c = new Builder()
98
- .u32(0x02014b50)
99
- .u16(info.versionMadeBy)
100
- .u16(needed)
101
- .u16(info.flags)
87
+ this.central.push(centralRecord(info, localOffset));
88
+ }
89
+ /**
90
+ * Writes one entry whose sizes are known only once its data has gone out: a local header with the data descriptor
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.
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.
98
+ */
99
+ async streamEntry(info, chunks, totals, zip64 = false) {
100
+ const flags = info.flags | 0x0008;
101
+ const versionNeeded = zip64 ? Math.max(45, info.versionNeeded) : info.versionNeeded;
102
+ const localOffset = this.offset;
103
+ const local = new Builder()
104
+ .u32(0x04034b50)
105
+ .u16(versionNeeded)
106
+ .u16(flags)
102
107
  .u16(info.method)
103
108
  .u16(info.time)
104
109
  .u16(info.date)
105
- .u32(info.crc)
106
- .u32(info.compressedSize >= MAX32 ? MAX32 : info.compressedSize)
107
- .u32(info.size >= MAX32 ? MAX32 : info.size)
110
+ .u32(0)
111
+ .u32(zip64 ? MAX32 : 0)
112
+ .u32(zip64 ? MAX32 : 0)
108
113
  .u16(info.name.length)
109
- .u16(info.extras.length + (z64Bytes.length ? z64Bytes.length + 4 : 0))
110
- .u16(info.comment.length)
111
- .u16(0)
112
- .u16(info.internalAttr)
113
- .u32(info.externalAttr)
114
- .u32(far ? MAX32 : localOffset)
114
+ .u16(info.extras.length + (zip64 ? 20 : 0))
115
115
  .bytes(info.name);
116
- if (z64Bytes.length)
117
- c.u16(0x0001).u16(z64Bytes.length).bytes(z64Bytes);
118
- c.bytes(info.extras).bytes(info.comment);
119
- this.central.push(c.build());
116
+ if (zip64)
117
+ local.u16(0x0001).u16(16).u64(0).u64(0);
118
+ local.bytes(info.extras);
119
+ await this.put(local.build());
120
+ let compressedSize = 0;
121
+ for await (const c of chunks) {
122
+ compressedSize += c.length;
123
+ await this.put(c);
124
+ }
125
+ const { crc, size } = totals();
126
+ const descriptor = new Builder().u32(0x08074b50).u32(crc);
127
+ if (zip64 || size >= MAX32 || compressedSize >= MAX32)
128
+ descriptor.u64(compressedSize).u64(size);
129
+ else
130
+ descriptor.u32(compressedSize).u32(size);
131
+ await this.put(descriptor.build());
132
+ this.central.push(centralRecord({ ...info, versionNeeded, flags, crc, size, compressedSize }, localOffset));
120
133
  }
121
134
  /** Writes the central directory and the end records, with the archive's comment. */
122
135
  async end(comment) {
@@ -145,6 +158,42 @@ class ZipOutput {
145
158
  }
146
159
  }
147
160
  exports.ZipOutput = ZipOutput;
161
+ /** The central directory record of an entry whose local header starts at `localOffset`. */
162
+ function centralRecord(info, localOffset) {
163
+ const far = localOffset >= MAX32;
164
+ const z64 = new Builder();
165
+ if (info.size >= MAX32)
166
+ z64.u64(info.size);
167
+ if (info.compressedSize >= MAX32)
168
+ z64.u64(info.compressedSize);
169
+ if (far)
170
+ z64.u64(localOffset);
171
+ const z64Bytes = z64.build();
172
+ const needed = z64Bytes.length ? Math.max(45, info.versionNeeded) : info.versionNeeded;
173
+ const c = new Builder()
174
+ .u32(0x02014b50)
175
+ .u16(info.versionMadeBy)
176
+ .u16(needed)
177
+ .u16(info.flags)
178
+ .u16(info.method)
179
+ .u16(info.time)
180
+ .u16(info.date)
181
+ .u32(info.crc)
182
+ .u32(info.compressedSize >= MAX32 ? MAX32 : info.compressedSize)
183
+ .u32(info.size >= MAX32 ? MAX32 : info.size)
184
+ .u16(info.name.length)
185
+ .u16(info.extras.length + (z64Bytes.length ? z64Bytes.length + 4 : 0))
186
+ .u16(info.comment.length)
187
+ .u16(0)
188
+ .u16(info.internalAttr)
189
+ .u32(info.externalAttr)
190
+ .u32(far ? MAX32 : localOffset)
191
+ .bytes(info.name);
192
+ if (z64Bytes.length)
193
+ c.u16(0x0001).u16(z64Bytes.length).bytes(z64Bytes);
194
+ c.bytes(info.extras).bytes(info.comment);
195
+ return c.build();
196
+ }
148
197
  /** Writes `zip` again with the entries `plan` keeps. */
149
198
  async function writeZip(zip, plan, sink, context) {
150
199
  const out = new ZipOutput(sink);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patrickdk77/archive-defuse",
3
- "version": "0.1.0",
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",