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