@patrickdk77/archive-defuse 0.1.0 → 0.1.1
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 +33 -0
- package/dist/detect.d.ts +10 -0
- package/dist/detect.js +95 -0
- package/dist/formats.d.ts +10 -0
- package/dist/formats.js +26 -17
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -1
- package/dist/index.mjs +1 -1
- package/dist/pack.d.ts +7 -0
- package/dist/pack.js +77 -1
- package/dist/plugins.js +3 -2
- package/dist/types.d.ts +27 -0
- package/dist/zip-writer.d.ts +10 -0
- package/dist/zip-writer.js +71 -29
- package/package.json +1 -1
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.
|
|
@@ -278,6 +307,10 @@ const result = JSON.parse(await zip.text('result.json'));
|
|
|
278
307
|
|
|
279
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.
|
|
280
309
|
|
|
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
|
+
|
|
312
|
+
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
|
+
|
|
281
314
|
`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
315
|
|
|
283
316
|
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/detect.d.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,3 +1,4 @@
|
|
|
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';
|
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.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; } });
|
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, 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
|
@@ -50,6 +50,13 @@ export interface PackOptions {
|
|
|
50
50
|
memoryThreshold?: number;
|
|
51
51
|
/** Stops the work when aborted: the sink is aborted and the call throws the signal's reason. */
|
|
52
52
|
signal?: AbortSignal;
|
|
53
|
+
/**
|
|
54
|
+
* ZIP only: write each file as its data arrives, deflated on the way, with its CRC and sizes in a data descriptor
|
|
55
|
+
* after the data. Nothing goes to a temporary file. An entry with `store: true` is held in memory, since a stored
|
|
56
|
+
* entry keeps its sizes in its header. `ratioLimit` does not apply, since an entry is written before its ratio is
|
|
57
|
+
* known.
|
|
58
|
+
*/
|
|
59
|
+
streaming?: boolean;
|
|
53
60
|
}
|
|
54
61
|
/** Thrown for an entry this package will not write, before anything of the entry is written. */
|
|
55
62
|
export declare class PackError extends Error {
|
package/dist/pack.js
CHANGED
|
@@ -28,6 +28,8 @@ const enc = new TextEncoder();
|
|
|
28
28
|
/** The reading default of `limits.compressionRatio`. */
|
|
29
29
|
const DEFAULT_RATIO = 1000;
|
|
30
30
|
const toBytes = (c) => (typeof c === 'string' ? enc.encode(c) : c);
|
|
31
|
+
/** Bytes that small chunks of an entry are gathered into before they go on. */
|
|
32
|
+
const BATCH = 64 * 1024;
|
|
31
33
|
/** `chunks`, with `check` before each one, so an aborted signal stops a long read. */
|
|
32
34
|
async function* checked(chunks, check) {
|
|
33
35
|
for await (const c of chunks) {
|
|
@@ -45,11 +47,28 @@ async function* chunksOf(data) {
|
|
|
45
47
|
yield b;
|
|
46
48
|
return;
|
|
47
49
|
}
|
|
50
|
+
// Small chunks, such as one row of a sheet each, go to the compressor in pieces of BATCH bytes. zlib does the same
|
|
51
|
+
// work either way, and each write costs as much as a large one.
|
|
52
|
+
let pending = [];
|
|
53
|
+
let held = 0;
|
|
48
54
|
for await (const c of data) {
|
|
49
55
|
const b = toBytes(c);
|
|
50
|
-
if (b.length)
|
|
56
|
+
if (!b.length)
|
|
57
|
+
continue;
|
|
58
|
+
if (held === 0 && b.length >= BATCH) {
|
|
51
59
|
yield b;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
pending.push(b);
|
|
63
|
+
held += b.length;
|
|
64
|
+
if (held >= BATCH) {
|
|
65
|
+
yield Buffer.concat(pending, held);
|
|
66
|
+
pending = [];
|
|
67
|
+
held = 0;
|
|
68
|
+
}
|
|
52
69
|
}
|
|
70
|
+
if (held)
|
|
71
|
+
yield Buffer.concat(pending, held);
|
|
53
72
|
}
|
|
54
73
|
/** Why `createArchive` refuses a name, for each name rule of the inspection that changes a name. */
|
|
55
74
|
const REFUSED = {
|
|
@@ -180,6 +199,10 @@ async function writeZipEntries(entries, sink, options, temp, check) {
|
|
|
180
199
|
await out.entry(info, () => chunksOf(undefined), s.name);
|
|
181
200
|
continue;
|
|
182
201
|
}
|
|
202
|
+
if (options.streaming) {
|
|
203
|
+
await streamZipEntry(out, s, info, level, check);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
183
206
|
// The content is read once, into memory or a temporary file, and deflated from there, so it can be stored instead
|
|
184
207
|
// when deflate gains nothing or passes the ratio limit.
|
|
185
208
|
const { spill: raw, crc } = await spillOf(s, temp, threshold, check);
|
|
@@ -206,6 +229,56 @@ async function writeZipEntries(entries, sink, options, temp, check) {
|
|
|
206
229
|
}
|
|
207
230
|
await out.end(enc.encode(options.comment ?? ''));
|
|
208
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Writes one entry of a streaming ZIP archive. A file is deflated as its data arrives and gets a data descriptor; at
|
|
234
|
+
* level 0, deflate writes stored blocks. An empty file is stored with its sizes. A link's target and a file the caller
|
|
235
|
+
* stores are held in memory, so their headers carry their sizes, since a stored entry with a data descriptor is how a
|
|
236
|
+
* second, hidden entry gets past a reader of the central directory.
|
|
237
|
+
*/
|
|
238
|
+
async function streamZipEntry(out, s, info, level, check) {
|
|
239
|
+
const it = checked(await contentOf(s), check)[Symbol.asyncIterator]();
|
|
240
|
+
let first;
|
|
241
|
+
try {
|
|
242
|
+
first = await it.next();
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
await it.return?.(undefined);
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
if (s.kind === 'symlink' || s.entry.store === true || first.done) {
|
|
249
|
+
const parts = [];
|
|
250
|
+
let crc = 0;
|
|
251
|
+
try {
|
|
252
|
+
for (let r = first; !r.done; r = await it.next()) {
|
|
253
|
+
crc = (0, crc32_1.crc32)(r.value, crc);
|
|
254
|
+
parts.push(r.value);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await it.return?.(undefined);
|
|
259
|
+
}
|
|
260
|
+
const data = Buffer.concat(parts);
|
|
261
|
+
Object.assign(info, { method: 0, versionNeeded: 10, crc, size: data.length, compressedSize: data.length });
|
|
262
|
+
await out.entry(info, () => chunksOf(data), s.name);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
let crc = 0;
|
|
266
|
+
let size = 0;
|
|
267
|
+
const raw = async function* () {
|
|
268
|
+
try {
|
|
269
|
+
for (let r = first; !r.done; r = await it.next()) {
|
|
270
|
+
crc = (0, crc32_1.crc32)(r.value, crc);
|
|
271
|
+
size += r.value.length;
|
|
272
|
+
yield r.value;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await it.return?.(undefined);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
Object.assign(info, { method: 8, versionNeeded: 20 });
|
|
280
|
+
await out.streamEntry(info, (0, codecs_1.deflateRaw)(raw(), level), () => ({ crc, size }));
|
|
281
|
+
}
|
|
209
282
|
// ---------------------------------------------------------------------------------------------------------------
|
|
210
283
|
// tar
|
|
211
284
|
const TAR_TYPES = { file: '0', directory: '5', symlink: '2' };
|
|
@@ -427,6 +500,9 @@ async function deliver(produce, sink) {
|
|
|
427
500
|
async function createArchive(format, entries, sink, options = {}) {
|
|
428
501
|
if (!ARCHIVE_FORMATS.has(format))
|
|
429
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}`);
|
|
430
506
|
const now = new Date();
|
|
431
507
|
const temp = new io_1.TempDir(options.tempDir);
|
|
432
508
|
const check = () => options.signal?.throwIfAborted();
|
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
|
-
|
|
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. */
|
package/dist/zip-writer.d.ts
CHANGED
|
@@ -34,6 +34,16 @@ 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. 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.
|
|
42
|
+
*/
|
|
43
|
+
streamEntry(info: Written, chunks: AsyncIterable<Uint8Array>, totals: () => {
|
|
44
|
+
crc: number;
|
|
45
|
+
size: number;
|
|
46
|
+
}): Promise<void>;
|
|
37
47
|
/** Writes the central directory and the end records, with the archive's comment. */
|
|
38
48
|
end(comment: Uint8Array): Promise<void>;
|
|
39
49
|
}
|
package/dist/zip-writer.js
CHANGED
|
@@ -84,39 +84,45 @@ 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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
.u16(
|
|
101
|
-
.u16(
|
|
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. 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.
|
|
94
|
+
*/
|
|
95
|
+
async streamEntry(info, chunks, totals) {
|
|
96
|
+
const flags = info.flags | 0x0008;
|
|
97
|
+
const localOffset = this.offset;
|
|
98
|
+
const local = new Builder()
|
|
99
|
+
.u32(0x04034b50)
|
|
100
|
+
.u16(info.versionNeeded)
|
|
101
|
+
.u16(flags)
|
|
102
102
|
.u16(info.method)
|
|
103
103
|
.u16(info.time)
|
|
104
104
|
.u16(info.date)
|
|
105
|
-
.u32(
|
|
106
|
-
.u32(
|
|
107
|
-
.u32(
|
|
105
|
+
.u32(0)
|
|
106
|
+
.u32(0)
|
|
107
|
+
.u32(0)
|
|
108
108
|
.u16(info.name.length)
|
|
109
|
-
.u16(info.extras.length
|
|
110
|
-
.
|
|
111
|
-
.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
.u16(info.extras.length)
|
|
110
|
+
.bytes(info.name)
|
|
111
|
+
.bytes(info.extras);
|
|
112
|
+
await this.put(local.build());
|
|
113
|
+
let compressedSize = 0;
|
|
114
|
+
for await (const c of chunks) {
|
|
115
|
+
compressedSize += c.length;
|
|
116
|
+
await this.put(c);
|
|
117
|
+
}
|
|
118
|
+
const { crc, size } = totals();
|
|
119
|
+
const descriptor = new Builder().u32(0x08074b50).u32(crc);
|
|
120
|
+
if (size >= MAX32 || compressedSize >= MAX32)
|
|
121
|
+
descriptor.u64(compressedSize).u64(size);
|
|
122
|
+
else
|
|
123
|
+
descriptor.u32(compressedSize).u32(size);
|
|
124
|
+
await this.put(descriptor.build());
|
|
125
|
+
this.central.push(centralRecord({ ...info, flags, crc, size, compressedSize }, localOffset));
|
|
120
126
|
}
|
|
121
127
|
/** Writes the central directory and the end records, with the archive's comment. */
|
|
122
128
|
async end(comment) {
|
|
@@ -145,6 +151,42 @@ class ZipOutput {
|
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
exports.ZipOutput = ZipOutput;
|
|
154
|
+
/** The central directory record of an entry whose local header starts at `localOffset`. */
|
|
155
|
+
function centralRecord(info, localOffset) {
|
|
156
|
+
const far = localOffset >= MAX32;
|
|
157
|
+
const z64 = new Builder();
|
|
158
|
+
if (info.size >= MAX32)
|
|
159
|
+
z64.u64(info.size);
|
|
160
|
+
if (info.compressedSize >= MAX32)
|
|
161
|
+
z64.u64(info.compressedSize);
|
|
162
|
+
if (far)
|
|
163
|
+
z64.u64(localOffset);
|
|
164
|
+
const z64Bytes = z64.build();
|
|
165
|
+
const needed = z64Bytes.length ? Math.max(45, info.versionNeeded) : info.versionNeeded;
|
|
166
|
+
const c = new Builder()
|
|
167
|
+
.u32(0x02014b50)
|
|
168
|
+
.u16(info.versionMadeBy)
|
|
169
|
+
.u16(needed)
|
|
170
|
+
.u16(info.flags)
|
|
171
|
+
.u16(info.method)
|
|
172
|
+
.u16(info.time)
|
|
173
|
+
.u16(info.date)
|
|
174
|
+
.u32(info.crc)
|
|
175
|
+
.u32(info.compressedSize >= MAX32 ? MAX32 : info.compressedSize)
|
|
176
|
+
.u32(info.size >= MAX32 ? MAX32 : info.size)
|
|
177
|
+
.u16(info.name.length)
|
|
178
|
+
.u16(info.extras.length + (z64Bytes.length ? z64Bytes.length + 4 : 0))
|
|
179
|
+
.u16(info.comment.length)
|
|
180
|
+
.u16(0)
|
|
181
|
+
.u16(info.internalAttr)
|
|
182
|
+
.u32(info.externalAttr)
|
|
183
|
+
.u32(far ? MAX32 : localOffset)
|
|
184
|
+
.bytes(info.name);
|
|
185
|
+
if (z64Bytes.length)
|
|
186
|
+
c.u16(0x0001).u16(z64Bytes.length).bytes(z64Bytes);
|
|
187
|
+
c.bytes(info.extras).bytes(info.comment);
|
|
188
|
+
return c.build();
|
|
189
|
+
}
|
|
148
190
|
/** Writes `zip` again with the entries `plan` keeps. */
|
|
149
191
|
async function writeZip(zip, plan, sink, context) {
|
|
150
192
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|