@patrickdk77/archive-defuse 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +345 -2
- package/dist/aes7z.d.ts +16 -0
- package/dist/aes7z.js +131 -0
- package/dist/alone.d.ts +21 -0
- package/dist/alone.js +65 -0
- package/dist/bcj2.d.ts +21 -0
- package/dist/bcj2.js +140 -0
- package/dist/bzip2.d.ts +21 -0
- package/dist/bzip2.js +688 -0
- package/dist/cab.d.ts +63 -0
- package/dist/cab.js +493 -0
- package/dist/checks.d.ts +23 -0
- package/dist/checks.js +123 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +364 -0
- package/dist/codecs.d.ts +39 -0
- package/dist/codecs.js +124 -0
- package/dist/coders.d.ts +58 -0
- package/dist/coders.js +392 -0
- package/dist/crc32.d.ts +8 -0
- package/dist/crc32.js +59 -0
- package/dist/encoder.d.ts +44 -0
- package/dist/encoder.js +114 -0
- package/dist/engine.d.ts +44 -0
- package/dist/engine.js +753 -0
- package/dist/errors.d.ts +64 -0
- package/dist/errors.js +98 -0
- package/dist/filters.d.ts +34 -0
- package/dist/filters.js +385 -0
- package/dist/findings.d.ts +26 -0
- package/dist/findings.js +176 -0
- package/dist/formats.d.ts +14 -0
- package/dist/formats.js +97 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +50 -0
- package/dist/index.mjs +3 -0
- package/dist/inflate64.d.ts +6 -0
- package/dist/inflate64.js +388 -0
- package/dist/io.d.ts +78 -0
- package/dist/io.js +379 -0
- package/dist/lzma-formats.d.ts +75 -0
- package/dist/lzma-formats.js +373 -0
- package/dist/lzma-sniff.d.ts +20 -0
- package/dist/lzma-sniff.js +73 -0
- package/dist/lzma-writers.d.ts +35 -0
- package/dist/lzma-writers.js +276 -0
- package/dist/lzma1/LICENSE +201 -0
- package/dist/lzma1/NOTICE +36 -0
- package/dist/lzma1/crc32.d.ts +4 -0
- package/dist/lzma1/crc32.js +19 -0
- package/dist/lzma1/hash234.d.ts +22 -0
- package/dist/lzma1/hash234.js +75 -0
- package/dist/lzma1/hc4.d.ts +30 -0
- package/dist/lzma1/hc4.js +142 -0
- package/dist/lzma1/header.d.ts +32 -0
- package/dist/lzma1/header.js +73 -0
- package/dist/lzma1/lz-decoder.d.ts +41 -0
- package/dist/lzma1/lz-decoder.js +128 -0
- package/dist/lzma1/lz-encoder.d.ts +87 -0
- package/dist/lzma1/lz-encoder.js +166 -0
- package/dist/lzma1/lzma-coder.d.ts +107 -0
- package/dist/lzma1/lzma-coder.js +162 -0
- package/dist/lzma1/lzma-decoder.d.ts +36 -0
- package/dist/lzma1/lzma-decoder.js +484 -0
- package/dist/lzma1/lzma-encoder-fast.d.ts +13 -0
- package/dist/lzma1/lzma-encoder-fast.js +114 -0
- package/dist/lzma1/lzma-encoder.d.ts +105 -0
- package/dist/lzma1/lzma-encoder.js +497 -0
- package/dist/lzma1/lzma2-decoder.d.ts +85 -0
- package/dist/lzma1/lzma2-decoder.js +249 -0
- package/dist/lzma1/range-coder.d.ts +18 -0
- package/dist/lzma1/range-coder.js +23 -0
- package/dist/lzma1/range-decoder.d.ts +41 -0
- package/dist/lzma1/range-decoder.js +78 -0
- package/dist/lzma1/range-encoder.d.ts +53 -0
- package/dist/lzma1/range-encoder.js +269 -0
- package/dist/lzma1-stream.d.ts +60 -0
- package/dist/lzma1-stream.js +202 -0
- package/dist/model.d.ts +141 -0
- package/dist/model.js +16 -0
- package/dist/names.d.ts +13 -0
- package/dist/names.js +66 -0
- package/dist/pack.d.ts +72 -0
- package/dist/pack.js +514 -0
- package/dist/plugins.d.ts +13 -0
- package/dist/plugins.js +55 -0
- package/dist/rules.d.ts +25 -0
- package/dist/rules.js +403 -0
- package/dist/score.d.ts +17 -0
- package/dist/score.js +58 -0
- package/dist/seven.d.ts +75 -0
- package/dist/seven.js +492 -0
- package/dist/sevenzip.d.ts +133 -0
- package/dist/sevenzip.js +529 -0
- package/dist/single.d.ts +103 -0
- package/dist/single.js +453 -0
- package/dist/sniff.d.ts +20 -0
- package/dist/sniff.js +224 -0
- package/dist/stream-meter.d.ts +39 -0
- package/dist/stream-meter.js +54 -0
- package/dist/tar.d.ts +102 -0
- package/dist/tar.js +765 -0
- package/dist/types.d.ts +284 -0
- package/dist/types.js +133 -0
- package/dist/unpack.d.ts +67 -0
- package/dist/unpack.js +194 -0
- package/dist/xz.d.ts +25 -0
- package/dist/xz.js +333 -0
- package/dist/zip-lzma.d.ts +15 -0
- package/dist/zip-lzma.js +137 -0
- package/dist/zip-writer.d.ts +41 -0
- package/dist/zip-writer.js +263 -0
- package/dist/zip.d.ts +99 -0
- package/dist/zip.js +901 -0
- package/dist/zipcrypt.d.ts +47 -0
- package/dist/zipcrypt.js +233 -0
- package/dist/zstd.d.ts +25 -0
- package/dist/zstd.js +160 -0
- package/package.json +94 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 patrickdk77
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,346 @@
|
|
|
1
|
-
#
|
|
1
|
+
# archive-defuse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Inspect and defuse ZIP, tar, gzip, bzip2, zstd, Brotli, cabinet, 7z, xz and .lzma files. archive-defuse reads the archive with its own parser, reports names that leave the extraction folder, links out of the tree, device files, colliding names, data hidden around or between entries, headers that two readers would read differently, encrypted entries and compression bombs, and scores the risk from 0 to 100. It hands every file inside the archive to file plugins, and writes a defused archive in the same format. It also makes and reads archives as a library, in place of archiver or adm-zip, as [Making and reading archives](#making-and-reading-archives) shows. It has no runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install @patrickdk77/archive-defuse
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Node 18 or later. zstd needs a Node that has it in zlib (22.15, 23.8 or later).
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const { inspectArchive, disarmArchive, passThrough } = require('@patrickdk77/archive-defuse');
|
|
15
|
+
|
|
16
|
+
const report = await inspectArchive(bytes, { filePlugins: [passThrough(['txt', 'csv'])] });
|
|
17
|
+
report.status; // 'clean', 'strippable' or 'rejected'
|
|
18
|
+
report.risk; // 'NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL' or 'UNKNOWN'
|
|
19
|
+
report.score; // 0 to 100, null when the archive could not be inspected
|
|
20
|
+
report.format; // 'zip', 'tar', 'gzip', 'bzip2', 'zstd', 'brotli', 'cab', '7z', 'xz' or 'lzma'
|
|
21
|
+
report.findings; // [{ category, detail, description, action, location, data, attachment }]
|
|
22
|
+
|
|
23
|
+
const result = await disarmArchive(bytes, { filePlugins: [passThrough(['txt', 'csv'])] });
|
|
24
|
+
if (result.status !== 'rejected') save(result.bytes);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every file inside the archive goes to the file plugins. An entry no plugin accepts is removed, which is what pdf-defuse does with an attached file no plugin takes. With no plugins at all, the defused archive keeps its folders and links and drops every file. [The defuse family](#the-defuse-family) shows the router that hands each entry to the package for its format.
|
|
28
|
+
|
|
29
|
+
ES modules work too: `import { disarmArchive } from '@patrickdk77/archive-defuse'`.
|
|
30
|
+
|
|
31
|
+
### Files and S3
|
|
32
|
+
|
|
33
|
+
`inspectArchiveSource(source, options)` and `disarmArchiveSource(source, sink, options)` take a source with `size()` and `read(offset, length)` and a sink with `write(chunk)` and `close()`, the shapes pdf-defuse uses. `bufferSource`, `bufferSink`, `fileSource`, `fileSink` and `writableSink` are exported. pdf-defuse's README shows a source and sink over S3.
|
|
34
|
+
|
|
35
|
+
Both functions close the source when the run ends. `disarmArchiveSource` copies the upload once, to memory up to `memoryThreshold` and to a temporary file above it, and reads only that copy. Nothing reaches the sink unless the rewritten archive passes its own inspection. A rejected archive leaves the sink untouched, and a failed copy calls the sink's `abort()`.
|
|
36
|
+
|
|
37
|
+
## Formats
|
|
38
|
+
|
|
39
|
+
The first bytes decide the format, never the name.
|
|
40
|
+
|
|
41
|
+
| Format | Reads | Writes |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| ZIP and ZIPX | stored, deflate, Deflate64 (method 9), bzip2 (method 12), LZMA (method 14), zstd (method 93) and XZ (method 95) entries; ZIP64; data descriptors; ZipCrypto and WinZip AES entries | stored and deflate entries, and Deflate64, bzip2, LZMA, zstd and XZ entries copied as they are |
|
|
44
|
+
| tar | v7, ustar, star, GNU long names and links, GNU old sparse files, pax extended and global headers, pax sparse files 0.0, 0.1 and 1.0, base-256 numbers | ustar headers, with pax records for what they cannot hold |
|
|
45
|
+
| gzip | one or more members | one member |
|
|
46
|
+
| bzip2 | one or more streams, randomised blocks included | one stream |
|
|
47
|
+
| zstd | frames and skippable frames | one frame |
|
|
48
|
+
| Brotli | one stream | one stream |
|
|
49
|
+
| cabinet | stored and MSZIP folders | stored and MSZIP folders |
|
|
50
|
+
| 7z | Copy, LZMA, LZMA2, Deflate, Deflate64, BZip2, Delta and 7zAES coders, the BCJ, BCJ2, PPC, IA64, ARM, ARMT, SPARC, ARM64 and RISCV filters, solid folders, encoded and encrypted headers, empty files, directories, anti-items, attributes and times | one LZMA folder per file and a plain header |
|
|
51
|
+
| xz | every stream, LZMA2 with the delta and branch filters, CRC32, CRC64 and SHA-256 checks | one stream with one LZMA2 block |
|
|
52
|
+
| .lzma | known and unknown sizes, with or without an end marker | the size in the header and no end marker |
|
|
53
|
+
|
|
54
|
+
An .lzma file has no signature, so its 13-byte header must look the way liblzma's own format guess requires: valid properties with lc + lp at most 4, a dictionary of 2^n or 2^n + 2^(n-1) bytes, a size that is unknown or below 256 GiB, and a first data byte of 0.
|
|
55
|
+
|
|
56
|
+
A file that starts with no signature of these formats is still read as a ZIP archive when an end of central directory record near its end leads to a central directory. That covers a ZIP with data in front of it, such as a self-extracting executable. A 7z signature header in the first megabyte, with a start header whose CRC matches, is read the same way. Brotli has no signature. A file that matches nothing else is Brotli when it decodes as one Brotli stream that ends where the file does, or that decodes to more than 32 MiB without an error.
|
|
57
|
+
|
|
58
|
+
gzip, bzip2, zstd, Brotli, xz and .lzma hold one file. archive-defuse treats each as an archive with one entry and hands that entry to the file plugins like any other. The tar inside a `.tar.gz` reaches the tar reader through a plugin, such as `archivePlugin()` or file-defuse's router.
|
|
59
|
+
|
|
60
|
+
What archive-defuse cannot decode rejects the archive with the UNKNOWN risk:
|
|
61
|
+
|
|
62
|
+
- ZIP entries compressed with PPMd, implode, shrink, reduce or another method that is not in the table, with COMPRESSION/UNSUPPORTED_METHOD.
|
|
63
|
+
- 7z folders that use PPMd, Zstandard, LZ4, Brotli or another coder not in the table, and xz filters this package does not know, with COMPRESSION/UNSUPPORTED_METHOD. A 7z format version other than 0, or 7zAES key derivation over 2^24 rounds, with COMPRESSION/UNSUPPORTED_FEATURE.
|
|
64
|
+
- Cabinet folders compressed with LZX or Quantum, with COMPRESSION/UNSUPPORTED_METHOD.
|
|
65
|
+
- zstd data on a Node without zstd, with COMPRESSION/UNAVAILABLE. A zstd frame that needs a dictionary or a window over 128 MiB, with COMPRESSION/UNSUPPORTED_METHOD.
|
|
66
|
+
- Encrypted entries no password opens, with ENCRYPTED/PASSWORD_REQUIRED, and PKWARE strong encryption or an encrypted central directory, with ENCRYPTED/UNSUPPORTED.
|
|
67
|
+
- One part of an archive split across several files, a cabinet that continues in another file, and a GNU multi-volume tar, with STRUCTURE/MULTI_VOLUME.
|
|
68
|
+
- RAR, with CORRUPTED/UNPARSEABLE.
|
|
69
|
+
|
|
70
|
+
## What it checks
|
|
71
|
+
|
|
72
|
+
Each rule decides by itself. A rule whose action is `strip` makes its own change in the rewrite and no other. It fixes the name, clears the bit, drops the field or removes the entry. Setting one rule to `info` changes that rule alone.
|
|
73
|
+
|
|
74
|
+
**Names.** The name rules read the name as the archive gives it, and fix it in a fixed order.
|
|
75
|
+
|
|
76
|
+
- NAME/TRAVERSAL: a `..` part, also with the dots and spaces Windows drops from the end of a part, such as `.. `. The rewrite drops the `..` parts.
|
|
77
|
+
- NAME/ABSOLUTE: a name that starts with `/` or `\`, UNC paths included. The rewrite drops every leading separator.
|
|
78
|
+
- NAME/DRIVE_LETTER: a name that starts with a drive letter, such as `C:`, `C:..\x` or `\\?\C:\`. The rewrite drops the drive.
|
|
79
|
+
- NAME/BACKSLASH: a backslash in a name, which Windows reads as a separator. The rewrite turns it into `/`. Not reported for a cabinet, whose separator it is.
|
|
80
|
+
- NAME/EMPTY_SEGMENT: `.` parts and empty parts. The rewrite drops them.
|
|
81
|
+
- NAME/CONTROL_CHARACTER: control characters, NUL included, and the characters that reorder text. The rewrite puts `_` in their place.
|
|
82
|
+
- NAME/WINDOWS_RESERVED: a device name such as `CON`, a character Windows refuses such as `:`, or a trailing dot or space. The rewrite puts `_` in their place, after a device name too.
|
|
83
|
+
- NAME/ENCODING: a ZIP name with non-ASCII bytes and no UTF-8 flag, a name flagged UTF-8 that is not valid UTF-8, a tar name that is not UTF-8, or a 7z name with a lone UTF-16 surrogate, which reads as U+FFFD. The rewrite writes every name in UTF-8, or in UTF-16 for 7z.
|
|
84
|
+
- NAME/UNICODE_PATH: an Info-ZIP Unicode path field that names another file than an ASCII header, or that belongs to another name.
|
|
85
|
+
- NAME/EMPTY: an empty name. The entry goes.
|
|
86
|
+
- NAME/TOO_LONG: a name over 4,096 bytes or a part over 255 bytes. `info`; as `strip` the entry goes.
|
|
87
|
+
|
|
88
|
+
A name the rules leave empty goes with its entry. A folder keeps its trailing slash and nothing else does. A 7z archive can store no names at all, and then the name rules skip its entries, as they skip the one entry of a gzip file without a name.
|
|
89
|
+
|
|
90
|
+
**Collisions.** These read the names the output would have, after the name rules, so a fixed name cannot land on another. Readers that overwrite keep the last of several entries, and the rewrite keeps the last.
|
|
91
|
+
|
|
92
|
+
- NAME/DUPLICATE: entries with the same name.
|
|
93
|
+
- NAME/NORMALIZATION_COLLISION: names equal after Unicode compatibility normalization (NFKD), such as an accented letter written as one code point or two, or a ligature and its letters.
|
|
94
|
+
- NAME/CASE_COLLISION: names equal after case folding, with a sharp s folded to `ss`.
|
|
95
|
+
- NAME/FILE_DIRECTORY_CLASH: a file or a hard link where another entry needs a folder, or a link named like a folder the archive lists. The file goes.
|
|
96
|
+
- NAME/SHORT_NAME_COLLISION: a name in the form of a Windows 8.3 alias, such as `LONGFI~1.TXT`, that another name in the same folder could get. The check predicts only the `~1` to `~9` style aliases; Windows switches to hashed aliases after a few collisions, and those are not predicted.
|
|
97
|
+
|
|
98
|
+
**Links.** Symbolic link targets resolve inside the archive's own tree, from the folder the link sits in, through other links of the archive by their case-folded names, 40 links at most.
|
|
99
|
+
|
|
100
|
+
- LINK/SYMLINK and LINK/HARDLINK: `info`, one per link.
|
|
101
|
+
- LINK/OUTSIDE: a symbolic link whose target leaves the tree, is absolute, cannot be resolved or loops, and a hard link whose target is absolute or has a `..` part. The link goes. A 7z entry with the Windows reparse-point attribute and no Unix mode holds a reparse buffer, not a path, so its target cannot be resolved.
|
|
102
|
+
- LINK/DANGLING: a hard link that names no entry before it, so extraction would link to whatever file has that name. The link goes.
|
|
103
|
+
- LINK/THROUGH_LINK: an entry whose path runs through a symbolic link of the archive. The entry goes.
|
|
104
|
+
|
|
105
|
+
A tar hard link names its target from the top of the archive. When the rewrite renames the target, the link names the new name. When the target goes, the link goes with it.
|
|
106
|
+
|
|
107
|
+
**Special files.** SPECIAL/DEVICE, SPECIAL/FIFO, SPECIAL/SOCKET and SPECIAL/UNKNOWN_TYPE, for a tar type or Unix file type no rule names, and SPECIAL/ANTI_ITEM, a 7z anti-item, which tells an updater to delete a file. Each entry goes.
|
|
108
|
+
|
|
109
|
+
**Permissions.** PERMISSIONS/SETUID, PERMISSIONS/SETGID and PERMISSIONS/STICKY report the bit on any entry, folders included, and the rewrite clears it. PERMISSIONS/CAPABILITY reports a `security.capability` extended attribute in a pax record, and the rewrite drops it.
|
|
110
|
+
|
|
111
|
+
**Structure.** Each of these is a place where two readers can see different archives.
|
|
112
|
+
|
|
113
|
+
- STRUCTURE/LEADING_BYTES and STRUCTURE/TRAILING_BYTES: data before or after the archive. tar's zero padding after the end blocks is not reported.
|
|
114
|
+
- STRUCTURE/POLYGLOT: that data reads as another format, such as a PDF in front of a ZIP or an executable stub. For 7z, xz and .lzma it also reports a ZIP end record that a ZIP reader would find from the end of the file, and a PDF header in the first 1024 bytes.
|
|
115
|
+
- STRUCTURE/HIDDEN_DATA: bytes of a ZIP or cabinet no entry covers, with a count of the local headers among them; skippable zstd frames; tar headers after the end of the archive, which readers that go past a lone zero block read.
|
|
116
|
+
- STRUCTURE/SEVERAL_END_RECORDS: another ZIP end record whose comment reaches the end of the file and that leads to a directory. The archive is read from the last end record whose comment fits in the file. An entry whose compressed bytes hold the other record is compressed afresh, so the copy does not carry it.
|
|
117
|
+
- STRUCTURE/HEADER_MISMATCH: a local header that disagrees with the central directory on the name, flags, method, CRC or sizes; a data descriptor that disagrees; an end record that disagrees with the directory or with its ZIP64 records; a pax size that differs from the header's own; a header name, for readers without pax or GNU long names, that leaves the folder while the real name does not.
|
|
118
|
+
- STRUCTURE/OFFSET_SHIFT: a central directory whose offsets do not count data in front of the archive, which some readers correct.
|
|
119
|
+
- STRUCTURE/OVERLAP: entries that share bytes.
|
|
120
|
+
- STRUCTURE/MULTIPLE_STREAMS: several gzip members, bzip2 streams, zstd frames or xz streams, after the first of which some readers stop.
|
|
121
|
+
- STRUCTURE/EXTRA_FIELD: a ZIP extra field record that holds neither times nor owners, a times or owners record without the layout Info-ZIP documents for it, a gzip extra field, a cabinet's reserved areas.
|
|
122
|
+
- STRUCTURE/NON_FILE_DATA: a folder or link that carries data.
|
|
123
|
+
- STRUCTURE/MALFORMED_HEADER: a header field that does not read as written, such as a gzip header with reserved flags or a pax value with a NUL in it, which the reader cuts at as GNU tar does.
|
|
124
|
+
|
|
125
|
+
The xz and 7z formats have structures of their own, and the reader checks them against each other:
|
|
126
|
+
|
|
127
|
+
- STRUCTURE/STREAM_PADDING (`info`): padding between or after xz streams. STRUCTURE/BAD_PADDING: padding of a length or content xz does not allow.
|
|
128
|
+
- STRUCTURE/INDEX_MISMATCH: an xz index or a footer's index size that disagrees with the blocks. STRUCTURE/HEADER_MISMATCH covers an xz stream footer that disagrees with its stream header.
|
|
129
|
+
- STRUCTURE/SIZE_MISMATCH: a declared size that disagrees with the data, such as an .lzma end marker before the size in its header.
|
|
130
|
+
- STRUCTURE/NO_CHECK (`info`): an xz stream with no integrity check. STRUCTURE/UNKNOWN_CHECK: a check type this package does not know, so the data went unverified.
|
|
131
|
+
- STRUCTURE/UNKNOWN_PROPERTY: a 7z header property this package does not keep.
|
|
132
|
+
- STRUCTURE/EMPTY_START_HEADER: a 7z start header of zeros, as an interrupted write leaves it. The reader looks for the header at the end of the file, where 7-Zip's recovery looks.
|
|
133
|
+
- STRUCTURE/HIDDEN_DATA and STRUCTURE/OVERLAP also cover the bytes of a 7z archive that no structure refers to, and structures that share bytes.
|
|
134
|
+
|
|
135
|
+
The rewrite settles each one by writing one consistent archive from what the reader took.
|
|
136
|
+
|
|
137
|
+
**Data.** CORRUPTED/ENTRY_DATA removes an entry whose data does not decode, decodes past its declared size, or does not match its CRC or checksum. A compressed file whose one stream does not decode rejects with CORRUPTED/DATA_ERROR, and one whose xz check does not match with CORRUPTED/CHECK_FAILED, which a 7z header CRC that does not match also gives. A header or table that cannot be read rejects with CORRUPTED/UNPARSEABLE, and an archive cut off inside an entry with CORRUPTED/TRUNCATED.
|
|
138
|
+
|
|
139
|
+
**Encryption.** ENCRYPTED/ZIPCRYPTO and ENCRYPTED/AES_128, AES_192 or AES_256 name the cipher (`info`). An entry a supplied password opens gets ENCRYPTED/DECRYPTED, and one the empty password opens ENCRYPTED/EMPTY_PASSWORD. Both are `strip`, since the rewrite writes the entry without encryption, with its compressed bytes as they were. A 7z header a password opens gets ENCRYPTED/HEADER (`strip`), since the rewrite writes the names in a plain header.
|
|
140
|
+
|
|
141
|
+
**Compression.** COMPRESSION/UNUSUAL_METHOD reports a Deflate64, bzip2, LZMA, zstd or XZ ZIP entry. It is `info` and the bytes are copied; as `strip` the rewrite compresses the entry again with deflate.
|
|
142
|
+
|
|
143
|
+
**Metadata.** METADATA/COMMENT reports an archive or entry comment. The rewrite keeps comments unless `stripMetadata` is on, which adds METADATA/STRIPPED.
|
|
144
|
+
|
|
145
|
+
**Entries.** ENTRY/NO_PLUGIN, ENTRY/TYPE_MISMATCH and the ENTRY/PLUGIN_* findings come from the file plugins, under [File plugins](#file-plugins).
|
|
146
|
+
|
|
147
|
+
An override that makes a rule `info` keeps what the rule reports where the rewrite can keep it, which covers leading and trailing data and the extra fields of a ZIP's central directory. Bytes the rewrite cannot keep, such as hidden data, data in a folder entry, a ZIP's local extra fields, the bytes after a malformed extra field and the extra fields of other formats, add PROCESSING/CONTENT_REMOVED.
|
|
148
|
+
|
|
149
|
+
## The defused copy
|
|
150
|
+
|
|
151
|
+
The rewrite writes the format it read.
|
|
152
|
+
|
|
153
|
+
- ZIP: entries in their own order, with local headers that carry the CRC and sizes, so no data descriptors. An entry nothing changed keeps its compressed bytes, without any encryption they had. An entry a plugin rewrote is deflated, or stored when deflate gains nothing or when its ratio would pass `limits.compressionRatio`. Times, permissions, comments and the extra fields that hold times and owners stay. The local header gets the central directory's copy of each field, so the rewrite sets an extended timestamp's flags to name only the times it holds. ZIP64 records appear where a size or offset needs them.
|
|
154
|
+
- tar: ustar headers, with pax records for long or non-ASCII names, large values and the pax records the archive had, such as extended attributes. A sparse file is written out in full. The archive ends with two zero blocks and is padded to 10,240 bytes.
|
|
155
|
+
- gzip: one member, with the name a rule fixed, its time and its comment. A single member nothing changed keeps its deflate stream.
|
|
156
|
+
- bzip2, zstd and Brotli: one stream. A single stream nothing changed is copied as it is. Several streams, or content a plugin rewrote, are compressed again with archive-defuse's bzip2 encoder or Node's zstd and Brotli.
|
|
157
|
+
- cabinet: a folder whose files are all kept as they were keeps its data blocks. Any other folder is built again in 32 KiB blocks, stored or MSZIP as it was, with checksums.
|
|
158
|
+
- 7z: one folder per file with content, each with one LZMA coder at lzma1's preset 1, a plain header with the names, folders, empty files, attributes and times that remain, and a CRC for every file. A file LZMA would compress past `limits.compressionRatio` is stored with the Copy coder instead. An archive with nothing left is the 32-byte signature header alone.
|
|
159
|
+
- xz: one stream, with the input's check type when every stream had the same one this package can compute, CRC64 otherwise. One block with LZMA2 alone, in independent chunks of at most 64 KiB, each compressed by lzma1's encoder at its preset 1 or stored when that is no smaller. Chunks that would start in the first 1024 bytes stay compressed, so the content does not show where other readers look for their signatures. A single stream with nothing to report is copied as it is.
|
|
160
|
+
- .lzma: the header with the size and no end marker, then the content compressed by lzma1's encoder. A file with nothing to report is copied as it is. The format cannot store data, so content that compresses past the ratio limit fails the output's check, as it does for bzip2.
|
|
161
|
+
|
|
162
|
+
lzma1's encoder is plain JavaScript, so the same input gives the same 7z, xz and .lzma output on every Node.
|
|
163
|
+
|
|
164
|
+
A compressed file whose one entry goes leaves nothing to write, so it rejects instead, as file-defuse does with an upload it would have to strip whole.
|
|
165
|
+
|
|
166
|
+
The output then goes through the same inspection, with the plugins credited by the MD5 of what they kept. Anything left to strip or reject fails it with PROCESSING/VERIFICATION_FAILED, and nothing reaches the sink. Defusing the output again gives the same bytes.
|
|
167
|
+
|
|
168
|
+
## File plugins
|
|
169
|
+
|
|
170
|
+
Every file entry goes to `filePlugins` in order, with pdf-defuse's shapes. A plugin's `accepts(file)` sees the entry's name as the archive gives it, the type `sniffType` finds in its first bytes, its size, its location such as `entry "docs/a.pdf"`, its depth and a source over the decoded and decrypted content. `process(file, sink, context)` gets the depth and the deadline. The first plugin that accepts the entry decides it.
|
|
171
|
+
|
|
172
|
+
- `passed` keeps the entry, with the bytes the plugin wrote when it wrote any.
|
|
173
|
+
- `scrubbed` keeps the bytes the plugin wrote.
|
|
174
|
+
- `removed`, a thrown error or a scrub that wrote nothing removes the entry, with ENTRY/PLUGIN_REMOVED or ENTRY/PLUGIN_FAILED.
|
|
175
|
+
|
|
176
|
+
The plugin's findings come into the report with `attachment` set to the entry's name. A `reject` inside the entry becomes `strip`, since the entry goes and the archive stays. When no plugin accepts an entry, ENTRY/NO_PLUGIN removes it. As `info`, the entry stays.
|
|
177
|
+
|
|
178
|
+
Before the plugins, a type check reports ENTRY/TYPE_MISMATCH (`info`) when the name's extension and the content disagree, as pdf-defuse does for attached files. A plugin never checks types.
|
|
179
|
+
|
|
180
|
+
`passThrough(types)` keeps entries by MIME type or extension, such as `passThrough(['text/csv', '.txt'])`.
|
|
181
|
+
|
|
182
|
+
`archivePlugin(options)` defuses archives inside other files. It accepts a file by its content. Without `options`, it takes the options of the archive-defuse run that found the file, so `filePlugins: [archivePlugin(), passThrough(['txt'])]` reads a `.tar.gz` down to its text files.
|
|
183
|
+
|
|
184
|
+
## Passwords
|
|
185
|
+
|
|
186
|
+
`password` takes one password or a list. archive-defuse tries each in order on each encrypted entry, and the empty password after them. Each password is tried as UTF-8 and, when it differs, as Latin-1.
|
|
187
|
+
|
|
188
|
+
A 7z folder opens with one password, the first under which the folder decodes up to its first file with a CRC and that file matches it. A folder with no CRC at all opens with the first password under which its first file decodes. Every file of the folder is read with that password, so a file that fails its CRC is removed and the folder is not decoded again with another. An encrypted 7z header is tried with each password when the archive opens. The key derivation is 7-Zip's, SHA-256 over 2^n rounds, and a header that asks for more than 2^24 rounds rejects.
|
|
189
|
+
|
|
190
|
+
ZipCrypto is archive-defuse's own code. A header that passes the check byte can come from a wrong password, so an entry whose data then fails its CRC goes on to the next password that passes. WinZip AES uses AES in ECB mode, with counter mode built on it, HMAC-SHA1, and PBKDF2 built on HMAC-SHA1. The password verifier and the authentication code both have to match.
|
|
191
|
+
|
|
192
|
+
## Limits
|
|
193
|
+
|
|
194
|
+
| Limit | Default | Counts |
|
|
195
|
+
|---|---|---|
|
|
196
|
+
| `fileSize` | none | Bytes in the upload |
|
|
197
|
+
| `entries` | 10,000 | Entries in the archive |
|
|
198
|
+
| `decompressedBytes` | 512 MiB | Decoded bytes of any one entry, declared or actual |
|
|
199
|
+
| `totalDecompressedBytes` | 2 GiB | Decoded bytes of the entries one run reads, declared or actual |
|
|
200
|
+
| `compressionRatio` | 1,000 | Decoded bytes per compressed byte of an entry, checked once it passes 1 MiB |
|
|
201
|
+
| `dictionaryBytes` | 256 MiB | Dictionary memory LZMA decoding needs: one LZMA or LZMA2 stream's dictionary, or those of all the coders of a 7z folder with their probability tables. A dictionary larger than the declared size counts at that size |
|
|
202
|
+
| `nestingDepth` | 10 | The deepest an archive may sit, with the upload at depth 0 and every format in between counting |
|
|
203
|
+
| `timeMs` | none | Milliseconds for the run and every file inside it |
|
|
204
|
+
|
|
205
|
+
Declared sizes are checked when the archive opens, before any entry is decoded. Actual sizes are checked as each entry decodes, whatever its headers declare, so a stream that lies about its size stops at the same limit. The ratio counts the compressed bytes the decoder has used so far, so a zero-filled bomb stops after about 1 MiB. A 7z folder counts its ratio over the whole folder as it decodes, so bytes skipped on the way to a later file count too. Nothing is allocated from a declared size. The holes of a sparse tar file count as stored, since its size was declared and checked.
|
|
206
|
+
|
|
207
|
+
A limit rejects the archive with its LIMIT finding, a null score and the UNKNOWN risk. `timeMs` becomes one deadline when the run starts, and every nested run shares it. A time or nesting limit in a nested run throws an error with `code: 'DEFUSE_LIMIT'` and `limit` set to `'time'` or `'nesting'`, which every package of the family passes up to the upload. An I/O error inside a nested run comes up with `code: 'DEFUSE_IO'`.
|
|
208
|
+
|
|
209
|
+
## Findings
|
|
210
|
+
|
|
211
|
+
Each finding has `category` and `detail`, a `description` safe to show the uploader, an `action` (`reject`, `strip` or `info`), and where it applies `location` and `data`. Past 200 findings of one kind, further ones raise a count on one more finding, as in pdf-defuse.
|
|
212
|
+
|
|
213
|
+
`allFindingSpecs()` returns every kind with its default action and weight. `actionOverrides` changes the action of a category or of one detail:
|
|
214
|
+
|
|
215
|
+
```js
|
|
216
|
+
await disarmArchive(bytes, {
|
|
217
|
+
actionOverrides: [
|
|
218
|
+
{ category: 'LINK', detail: 'SYMLINK', action: 'strip' },
|
|
219
|
+
{ category: 'NAME', action: 'reject' },
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A run that stops early, or cannot read the archive, rejects it with a null score and the UNKNOWN risk, whatever the overrides say.
|
|
225
|
+
|
|
226
|
+
## Scoring
|
|
227
|
+
|
|
228
|
+
The score adds the highest weight in full and a quarter of each other kind's weight, plus weights for some combinations, capped at 100. Each kind counts once. `scoreWeights` replaces weights, and `scoreBands` the lower bounds of LOW, MEDIUM, HIGH and CRITICAL (1, 25, 50 and 80). `scoreFindings`, `COMBINATIONS` and `DEFAULT_BANDS` are exported.
|
|
229
|
+
|
|
230
|
+
A finding from another package scores at its `weight`. An archive's findings carry the weights its run used when `archivePlugin` returns them, so a container in another package can score them.
|
|
231
|
+
|
|
232
|
+
## Options
|
|
233
|
+
|
|
234
|
+
| Option | Default | Purpose |
|
|
235
|
+
|---|---|---|
|
|
236
|
+
| `password` | none | One password or a list |
|
|
237
|
+
| `limits` | see Limits | |
|
|
238
|
+
| `stripMetadata` | false | Drops comments, owners and ids, times, extended attributes and the gzip time and system |
|
|
239
|
+
| `actionOverrides` | none | Changes the action of a category or detail |
|
|
240
|
+
| `scoreWeights` | none | Replaces weights |
|
|
241
|
+
| `scoreBands` | 1, 25, 50, 80 | Lower bounds of the risk bands |
|
|
242
|
+
| `filePlugins` | none | Plugins for the files inside the archive |
|
|
243
|
+
| `tempDir` | `os.tmpdir()` | Folder for temporary files |
|
|
244
|
+
| `memoryThreshold` | 8 MiB | Bytes held in memory before a temporary file takes over, for the upload's copy, each entry handed to the plugins, and the copies plugins keep together |
|
|
245
|
+
|
|
246
|
+
## The defuse family
|
|
247
|
+
|
|
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
|
+
|
|
250
|
+
## Making and reading archives
|
|
251
|
+
|
|
252
|
+
archive-defuse also makes archives and reads them, with no defusing involved, for code that would otherwise use archiver, adm-zip or tar-stream.
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
const { pipeline } = require('node:stream/promises');
|
|
256
|
+
const { createArchiveStream, openArchive } = require('@patrickdk77/archive-defuse');
|
|
257
|
+
|
|
258
|
+
// An xlsx is a ZIP. Entries go out in the order given, and each stream is read when its turn comes.
|
|
259
|
+
await pipeline(
|
|
260
|
+
createArchiveStream('zip', [
|
|
261
|
+
{ name: '[Content_Types].xml', data: contentTypesXml },
|
|
262
|
+
{ name: 'xl/worksheets/sheet1.xml', data: sheetStream },
|
|
263
|
+
{ name: 'attachment.pdf', data: () => bucket.openDownloadStream(fileId) },
|
|
264
|
+
]),
|
|
265
|
+
res,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// One file out of a ZIP an API sent back.
|
|
269
|
+
const zip = await openArchive(response.data);
|
|
270
|
+
const result = JSON.parse(await zip.text('result.json'));
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Making archives.** `createArchive(format, entries, sink, options)` writes to a sink and closes it. `createArchiveStream` returns a Node Readable instead, and `createArchiveBytes` a Uint8Array. The formats are `zip`, `tar`, `tar.gz`, `tar.bz2`, `tar.zst`, `tar.xz` and `7z`. `entries` is a list or an async iterable, and each entry has:
|
|
274
|
+
|
|
275
|
+
- `name`, with "/" between folders. A name that ends in "/" is a folder.
|
|
276
|
+
- `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.
|
|
277
|
+
- `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
|
+
|
|
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.
|
|
280
|
+
|
|
281
|
+
`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
|
+
|
|
283
|
+
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.
|
|
284
|
+
|
|
285
|
+
`signal` takes an AbortSignal. When it aborts, the work stops at the next chunk, the sink is aborted, and the call throws the signal's reason. Destroying the Readable of `createArchiveStream` or `compressStream` stops the work the same way. Hand it to `stream.pipeline`, which destroys it when the response closes early. `pipe` does not, and the work then waits for a read that never comes, holding its source and temporary files.
|
|
286
|
+
|
|
287
|
+
`compress(format, data, options)` compresses one file to bytes, `compressTo` writes it to a sink, and `compressStream` returns a Readable. The formats are `gzip`, `bzip2`, `zstd`, `brotli`, `xz` and `lzma`. zstd needs a Node that has it.
|
|
288
|
+
|
|
289
|
+
**Reading archives.** `openArchive(input, options)` takes bytes or a source, finds the format from the content, and lists the entries. The reader has `format`, `entries`, `find(name)`, `stream(entry)`, `bytes(entry)`, `text(entry)` and `close()`, and each takes an entry or its name. It reads every format the inspection reads. `decompress(input)` decodes a compressed file, such as gzip or xz, to its content. A compressed tar opens as the compressed file, whose one entry is the tar. To list the tar, open that entry's bytes with `openArchive` again.
|
|
290
|
+
|
|
291
|
+
openArchive applies no rule. A name comes as the archive stores it, so it can climb out with `..`, be absolute or repeat another, and code that writes entries to disk has to check each name, or defuse the archive first. The limits are the inspection's, with the same defaults: declared sizes are checked when the archive opens, every read counts toward the total, and the ratio limit stops a bomb as it decodes. `password` takes one password or a list. The XZ entries of a ZIP decode from a copy, kept in memory up to `memoryThreshold` (8 MiB) and in a temporary file in `tempDir` past it. What stops a read throws an ArchiveReadError, whose `category` and `detail` name the finding `inspectArchive` would report, such as `LIMIT` and `RATIO`.
|
|
292
|
+
|
|
293
|
+
## Command line
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
archive-defuse inspect <file> [options]
|
|
297
|
+
archive-defuse defuse <in> <out> [options]
|
|
298
|
+
|
|
299
|
+
--password-file <path> read passwords from a file, one per line; ARCHIVE_DEFUSE_PASSWORD holds one
|
|
300
|
+
--plugin <module> load plugins from a file or an installed package, repeatable
|
|
301
|
+
--config <module> load a whole options object, repeatable; the other options apply on top
|
|
302
|
+
--keep-type <type> keep entries of this MIME type or file extension unchanged, repeatable
|
|
303
|
+
--defuse-inner-archives defuse archives inside the archive, such as the tar inside a .tar.gz
|
|
304
|
+
--strip-metadata
|
|
305
|
+
--limit <name>=<value> fileSize, entries, decompressedBytes, totalDecompressedBytes, compressionRatio,
|
|
306
|
+
nestingDepth, timeMs; sizes accept kb, mb, gb
|
|
307
|
+
--json print the result as JSON
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Use `-` for stdin or stdout. stdin is copied to a temporary file first, and the copy stops one byte past `limits.fileSize` and at the time limit. `inspect` prints its report to stdout. `defuse` does too, unless the archive goes to stdout, and then the report goes to stderr. `defuse` refuses an output that is its input, since a failed write deletes the partial output.
|
|
311
|
+
|
|
312
|
+
`--password-file` reads one password per line. The tool drops a carriage return at the end of a line and skips blank lines, since the empty password is tried anyway. Spaces belong to the password. `ARCHIVE_DEFUSE_PASSWORD` holds one password, and a line break in it is part of that password. `--password-file` replaces it. There is no password flag, because other users can read command-line arguments from the process list. An unknown option shows in the error without the text after its `=`.
|
|
313
|
+
|
|
314
|
+
The text report shows each control character in an entry name as an escape such as `\u001b`. The JSON report escapes DEL and the C1 range as well.
|
|
315
|
+
|
|
316
|
+
Exit codes are 0 for clean, 1 for stripped or strippable, 2 for rejected and 3 for a usage or I/O error.
|
|
317
|
+
|
|
318
|
+
## What it does not check
|
|
319
|
+
|
|
320
|
+
- The content of the files inside the archive, which is the file plugins' business.
|
|
321
|
+
- Anything about the place an archive is extracted to, such as a symbolic link already there or Windows' Mark of the Web. archive-defuse writes no files of its own.
|
|
322
|
+
- Archives inside other bytes, apart from a ZIP that a reader finds from the end of the file. A cabinet that does not start the file is not read.
|
|
323
|
+
|
|
324
|
+
## Development
|
|
325
|
+
|
|
326
|
+
Development needs Node 22.12 or later, for mocha 12 and for chai 6 loaded with `require()`. The tests also read the rewritten archives with Python's `zipfile`, `tarfile`, `gzip` and `bz2`, so they need `python3`. Python reads each one in memory and writes no file.
|
|
327
|
+
|
|
328
|
+
```sh
|
|
329
|
+
npm install --ignore-scripts
|
|
330
|
+
npm run build
|
|
331
|
+
npm test
|
|
332
|
+
npm run lint
|
|
333
|
+
npm run format
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`npm test` runs the lint, builds the package and the tests, then runs mocha.
|
|
337
|
+
|
|
338
|
+
`node scripts/make-fixtures.js` writes every file in `test/fixtures` from scratch with the builders in `scripts/builders.js`, on a Node with zstd and with no other tool. `manifest.json` in that folder gives each fixture's plugins, options, verdicts and findings, and `test/fixtures.test.ts` checks every case.
|
|
339
|
+
|
|
340
|
+
`node scripts/make-lzma-fixtures.js` writes `test/fixtures/lzma/cases` and its manifest, with Python's `lzma` and `bz2` modules. `node scripts/make-deflate64-fixtures.js` writes `test/fixtures/deflate64` with 7-Zip's Deflate64 encoder, and the Deflate64 tests check the hand-made streams against `7z` when it is installed. Both write the same bytes on every run. `test/fixtures/lzma/xz-utils` holds test files from XZ Utils under the BSD Zero Clause License, with their README and LICENSE. No fixture ships in the package.
|
|
341
|
+
|
|
342
|
+
`npx husky` turns on the pre-commit hook, which runs Biome on the staged files and then `npm audit`.
|
|
343
|
+
|
|
344
|
+
## License
|
|
345
|
+
|
|
346
|
+
MIT AND Apache-2.0. The package is MIT, in LICENSE. The LZMA and LZMA2 decoders and the LZMA encoder in `src/lzma1` come from lzma1 by Filip Seman, under the Apache License 2.0. Its text and a NOTICE that lists what changed sit next to them, and both ship in `dist/lzma1`. lzma1 ports that code from XZ for Java, which is 0BSD. The branch converters and the delta filter are ported from XZ for Java directly.
|
package/dist/aes7z.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The properties of a 7zAES coder. */
|
|
2
|
+
export interface AesProps {
|
|
3
|
+
/** 2^numCyclesPower rounds of SHA-256 derive the key, or 0x3F for none. */
|
|
4
|
+
numCyclesPower: number;
|
|
5
|
+
salt: Uint8Array;
|
|
6
|
+
iv: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
/** Reads a 7zAES coder's properties: a byte of flags and power, a byte of sizes, then the salt and the IV. */
|
|
9
|
+
export declare function parseAesProps(p: Uint8Array): AesProps;
|
|
10
|
+
/**
|
|
11
|
+
* The AES-256 key 7-Zip derives from a password: SHA-256 over 2^numCyclesPower repetitions of salt, password and an
|
|
12
|
+
* 8-byte little-endian round counter. Rounds go to the hash in batches, which is the same input in fewer calls.
|
|
13
|
+
*/
|
|
14
|
+
export declare function deriveKey(password: string, props: AesProps, deadline: number | undefined): Buffer;
|
|
15
|
+
/** Decrypts AES-256-CBC as 7zAES uses it, with no padding: the coder's declared size cuts the last block. */
|
|
16
|
+
export declare function decryptAes(input: AsyncIterable<Uint8Array>, key: Buffer, iv: Uint8Array): AsyncGenerator<Uint8Array>;
|
package/dist/aes7z.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.parseAesProps = parseAesProps;
|
|
37
|
+
exports.deriveKey = deriveKey;
|
|
38
|
+
exports.decryptAes = decryptAes;
|
|
39
|
+
const crypto = __importStar(require("node:crypto"));
|
|
40
|
+
const errors_1 = require("./errors");
|
|
41
|
+
/**
|
|
42
|
+
* The most rounds of key derivation this package runs. A file could ask for 2^62, which no reader finishes, and 2^24
|
|
43
|
+
* already takes seconds.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_CYCLES_POWER = 24;
|
|
46
|
+
/** Reads a 7zAES coder's properties: a byte of flags and power, a byte of sizes, then the salt and the IV. */
|
|
47
|
+
function parseAesProps(p) {
|
|
48
|
+
if (p.length < 1)
|
|
49
|
+
throw new errors_1.ParseError('7zAES properties are missing');
|
|
50
|
+
const b0 = p[0];
|
|
51
|
+
const numCyclesPower = b0 & 0x3f;
|
|
52
|
+
if (numCyclesPower > MAX_CYCLES_POWER && numCyclesPower !== 0x3f) {
|
|
53
|
+
throw new errors_1.UnsupportedError(`7zAES key derivation over 2^${numCyclesPower} rounds`, false, { reason: `key derivation over 2^${numCyclesPower} rounds` });
|
|
54
|
+
}
|
|
55
|
+
if ((b0 & 0xc0) === 0) {
|
|
56
|
+
if (p.length !== 1)
|
|
57
|
+
throw new errors_1.ParseError('7zAES properties are longer than they say');
|
|
58
|
+
return { numCyclesPower, salt: new Uint8Array(0), iv: new Uint8Array(16) };
|
|
59
|
+
}
|
|
60
|
+
if (p.length < 2)
|
|
61
|
+
throw new errors_1.ParseError('7zAES properties are cut short');
|
|
62
|
+
const b1 = p[1];
|
|
63
|
+
const saltSize = ((b0 >>> 7) & 1) + (b1 >>> 4);
|
|
64
|
+
const ivSize = ((b0 >>> 6) & 1) + (b1 & 0x0f);
|
|
65
|
+
// The properties must hold exactly the salt and IV their sizes give.
|
|
66
|
+
if (p.length !== 2 + saltSize + ivSize)
|
|
67
|
+
throw new errors_1.ParseError('7zAES properties do not match their sizes');
|
|
68
|
+
const iv = new Uint8Array(16);
|
|
69
|
+
iv.set(p.subarray(2 + saltSize, 2 + saltSize + ivSize));
|
|
70
|
+
return { numCyclesPower, salt: p.slice(2, 2 + saltSize), iv };
|
|
71
|
+
}
|
|
72
|
+
/** The password as 7-Zip hashes it: UTF-16LE without a terminator. */
|
|
73
|
+
const passwordBytes = (password) => Buffer.from(password, 'utf16le');
|
|
74
|
+
/**
|
|
75
|
+
* The AES-256 key 7-Zip derives from a password: SHA-256 over 2^numCyclesPower repetitions of salt, password and an
|
|
76
|
+
* 8-byte little-endian round counter. Rounds go to the hash in batches, which is the same input in fewer calls.
|
|
77
|
+
*/
|
|
78
|
+
function deriveKey(password, props, deadline) {
|
|
79
|
+
const pw = passwordBytes(password);
|
|
80
|
+
if (props.numCyclesPower === 0x3f) {
|
|
81
|
+
const key = Buffer.alloc(32);
|
|
82
|
+
const raw = Buffer.concat([props.salt, pw]);
|
|
83
|
+
raw.copy(key, 0, 0, Math.min(32, raw.length));
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
86
|
+
if (props.numCyclesPower > MAX_CYCLES_POWER) {
|
|
87
|
+
throw new errors_1.UnsupportedError(`7zAES key derivation over 2^${props.numCyclesPower} rounds`, false, { reason: `key derivation over 2^${props.numCyclesPower} rounds` });
|
|
88
|
+
}
|
|
89
|
+
const unit = props.salt.length + pw.length + 8;
|
|
90
|
+
const rounds = 2 ** props.numCyclesPower;
|
|
91
|
+
const perBatch = Math.max(1, Math.min(rounds, Math.floor((1 << 20) / unit)));
|
|
92
|
+
const batch = Buffer.alloc(perBatch * unit);
|
|
93
|
+
for (let k = 0; k < perBatch; k++) {
|
|
94
|
+
const at = k * unit;
|
|
95
|
+
batch.set(props.salt, at);
|
|
96
|
+
batch.set(pw, at + props.salt.length);
|
|
97
|
+
}
|
|
98
|
+
const hash = crypto.createHash('sha256');
|
|
99
|
+
for (let done = 0; done < rounds;) {
|
|
100
|
+
(0, errors_1.checkTime)(deadline);
|
|
101
|
+
const n = Math.min(perBatch, rounds - done);
|
|
102
|
+
for (let k = 0; k < n; k++) {
|
|
103
|
+
const at = k * unit + props.salt.length + pw.length;
|
|
104
|
+
const round = done + k;
|
|
105
|
+
batch.writeUInt32LE(round % 0x100000000, at);
|
|
106
|
+
batch.writeUInt32LE(Math.floor(round / 0x100000000), at + 4);
|
|
107
|
+
}
|
|
108
|
+
hash.update(batch.subarray(0, n * unit));
|
|
109
|
+
done += n;
|
|
110
|
+
}
|
|
111
|
+
return hash.digest();
|
|
112
|
+
}
|
|
113
|
+
/** Decrypts AES-256-CBC as 7zAES uses it, with no padding: the coder's declared size cuts the last block. */
|
|
114
|
+
async function* decryptAes(input, key, iv) {
|
|
115
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
116
|
+
decipher.setAutoPadding(false);
|
|
117
|
+
for await (const chunk of input) {
|
|
118
|
+
const out = decipher.update(chunk);
|
|
119
|
+
if (out.length)
|
|
120
|
+
yield out;
|
|
121
|
+
}
|
|
122
|
+
let last;
|
|
123
|
+
try {
|
|
124
|
+
last = decipher.final();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new errors_1.DataError('encrypted data is not a whole number of AES blocks');
|
|
128
|
+
}
|
|
129
|
+
if (last.length)
|
|
130
|
+
yield last;
|
|
131
|
+
}
|
package/dist/alone.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Reader } from './io';
|
|
2
|
+
import type { Note } from './model';
|
|
3
|
+
import type { Limits, StreamCounter as Meter } from './stream-meter';
|
|
4
|
+
/** What reading an .lzma file found out, beyond its content. */
|
|
5
|
+
export interface AloneInfo {
|
|
6
|
+
notes: Note[];
|
|
7
|
+
/** Offset of the first byte after the LZMA data, when the file goes on past it. */
|
|
8
|
+
trailingAt?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface AloneEnv {
|
|
11
|
+
reader: Reader;
|
|
12
|
+
size: number;
|
|
13
|
+
limits: Limits;
|
|
14
|
+
meter: Meter;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Decodes an .lzma file: a 13-byte header with the properties, the dictionary size and the decoded size or all ones
|
|
18
|
+
* for unknown, then raw LZMA data. An end marker before the declared size is a size mismatch; data that goes on past
|
|
19
|
+
* it, or a stream without its end marker when the size is unknown, does not decode.
|
|
20
|
+
*/
|
|
21
|
+
export declare function readAlone(env: AloneEnv, info: AloneInfo): AsyncGenerator<Uint8Array>;
|
package/dist/alone.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readAlone = readAlone;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
const header_1 = require("./lzma1/header");
|
|
6
|
+
const lzma1_stream_1 = require("./lzma1-stream");
|
|
7
|
+
const types_1 = require("./types");
|
|
8
|
+
/** Bytes of input decoded per step. LZMA decodes to at most about 7,000 times its input. */
|
|
9
|
+
const SLICE = 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Decodes an .lzma file: a 13-byte header with the properties, the dictionary size and the decoded size or all ones
|
|
12
|
+
* for unknown, then raw LZMA data. An end marker before the declared size is a size mismatch; data that goes on past
|
|
13
|
+
* it, or a stream without its end marker when the size is unknown, does not decode.
|
|
14
|
+
*/
|
|
15
|
+
async function* readAlone(env, info) {
|
|
16
|
+
const { reader, size, meter } = env;
|
|
17
|
+
const head = await reader.read(0, header_1.HEADER_SIZE);
|
|
18
|
+
if (head.length < header_1.HEADER_SIZE)
|
|
19
|
+
throw new errors_1.DataError('the file ends in its header', true);
|
|
20
|
+
let header;
|
|
21
|
+
try {
|
|
22
|
+
header = (0, header_1.decodeHeader)(head);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
throw new errors_1.DataError(String(e.message).replace(/^(Corrupted|Unsupported) input: /, ''));
|
|
26
|
+
}
|
|
27
|
+
const declared = header.uncompressedSize === header_1.UNKNOWN_SIZE ? undefined : header.uncompressedSize;
|
|
28
|
+
if (declared !== undefined && declared > env.limits.decompressedBytes) {
|
|
29
|
+
throw new errors_1.StopError(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.DecompressedSize, 'Decompressed size limit exceeded', { limit: env.limits.decompressedBytes, declared });
|
|
30
|
+
}
|
|
31
|
+
const need = (0, lzma1_stream_1.effectiveDictSize)(header.dictSize, declared);
|
|
32
|
+
if (need > env.limits.dictionaryBytes)
|
|
33
|
+
throw new errors_1.StopError(types_1.ArchiveCategory.Limit, types_1.ArchiveDetail.DictionarySize, 'Dictionary size limit exceeded', { limit: env.limits.dictionaryBytes, dictionary: need });
|
|
34
|
+
const out = [];
|
|
35
|
+
const stream = new lzma1_stream_1.Lzma1Stream(header, declared, chunk => out.push(chunk.slice()), true);
|
|
36
|
+
let pos = header_1.HEADER_SIZE;
|
|
37
|
+
while (!stream.done && pos < size) {
|
|
38
|
+
const chunk = await reader.read(pos, 65536);
|
|
39
|
+
// Once the data ends, the rest of the chunk goes to the stream for the end-marker check.
|
|
40
|
+
for (let p = 0; p < chunk.length; p += SLICE) {
|
|
41
|
+
if (!stream.done)
|
|
42
|
+
meter.packed(Math.min(SLICE, chunk.length - p));
|
|
43
|
+
stream.write(chunk.subarray(p, p + SLICE));
|
|
44
|
+
for (const c of out.splice(0)) {
|
|
45
|
+
meter.add(c.length);
|
|
46
|
+
yield c;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
pos += chunk.length;
|
|
50
|
+
}
|
|
51
|
+
// The end-marker check needs a few bytes past the end of the data.
|
|
52
|
+
if (stream.done && pos < size)
|
|
53
|
+
stream.write(await reader.read(pos, 256));
|
|
54
|
+
stream.end();
|
|
55
|
+
for (const c of out.splice(0)) {
|
|
56
|
+
meter.add(c.length);
|
|
57
|
+
yield c;
|
|
58
|
+
}
|
|
59
|
+
if (declared !== undefined && stream.decoded < declared) {
|
|
60
|
+
info.notes.push({ category: types_1.ArchiveCategory.Structure, detail: types_1.ArchiveDetail.SizeMismatch, data: { reason: 'an end marker comes before the declared size', declared, actual: stream.decoded } });
|
|
61
|
+
}
|
|
62
|
+
const end = header_1.HEADER_SIZE + stream.consumed;
|
|
63
|
+
if (end < size)
|
|
64
|
+
info.trailingAt = end;
|
|
65
|
+
}
|