7zip-native 0.0.5 → 0.0.7
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 +47 -10
- package/bench/folder-concurrency.mts +143 -0
- package/dist/index.cjs +290 -15
- package/dist/index.d.cts +28 -2
- package/dist/index.d.mts +28 -2
- package/dist/index.d.ts +28 -2
- package/dist/index.mjs +285 -10
- package/native/addon.cpp +470 -0
- package/package.json +5 -2
- package/prebuilds/darwin-arm64/7zip_native.node +0 -0
- package/prebuilds/darwin-x64/7zip_native.node +0 -0
- package/prebuilds/linux-arm/7zip_native.node +0 -0
- package/prebuilds/linux-arm64/7zip_native.node +0 -0
- package/prebuilds/linux-ia32/7zip_native.node +0 -0
- package/prebuilds/linux-x64/7zip_native.node +0 -0
- package/prebuilds/win32-arm64/7z.dll +0 -0
- package/prebuilds/win32-arm64/7zip_native.node +0 -0
- package/prebuilds/win32-x64/7z.dll +0 -0
- package/prebuilds/win32-x64/7zip_native.node +0 -0
- package/scripts/build-native.mjs +119 -0
- package/scripts/clean-upstream-build.mjs +9 -0
- package/scripts/verify-prebuild.mjs +29 -0
package/README.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
# 7zip-native
|
|
5
5
|
|
|
6
|
+
### Current version `26.05`
|
|
7
|
+
Upstream 7-Zip archive engine from https://github.com/ip7z/7zip/
|
|
8
|
+
|
|
6
9
|
Native Node-API bindings for the 7-Zip archive engine. Use a typed, Promise-based Node.js API to inspect archives, read entry data, stream large files, or extract entries without launching `7z`, `7zz`, or another archive executable.
|
|
7
10
|
|
|
8
11
|
The package includes the native addon and the matching 7-Zip engine for each supported target. Compression and decompression are provided by the upstream 7-Zip implementation.
|
|
@@ -13,7 +16,7 @@ The package includes the native addon and the matching 7-Zip engine for each sup
|
|
|
13
16
|
npm install 7zip-native
|
|
14
17
|
```
|
|
15
18
|
|
|
16
|
-
The package requires Node.js 18 or newer.
|
|
19
|
+
The package requires Node.js 18 or newer.
|
|
17
20
|
|
|
18
21
|
## Quick start
|
|
19
22
|
|
|
@@ -136,17 +139,39 @@ await archive.extractToStream(entry.path, createWriteStream('./page.jpg'), {
|
|
|
136
139
|
|
|
137
140
|
This respects backpressure and ends the destination when extraction succeeds. A destination error, early close, or abort rejects the Promise.
|
|
138
141
|
|
|
142
|
+
### Creating ZIP, 7z, TAR, and WIM archives
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
await Archive.create('./comic.cbz', [
|
|
146
|
+
{sourcePath: './pages/cover.jpg', path: 'cover.jpg'},
|
|
147
|
+
{sourcePath: './pages/01.jpg', path: 'pages/01.jpg'},
|
|
148
|
+
], {
|
|
149
|
+
compressionLevel: 5,
|
|
150
|
+
overwrite: false,
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`Archive.create()` accepts regular source files and their desired archive-internal paths. It infers ZIP from `.zip` or `.cbz`, 7z from `.7z`, TAR from `.tar`, and WIM from `.wim`; set `format: 'zip'`, `'7z'`, `'tar'`, or `'wim'` to use another output extension. Compression level is an integer from 0 to 9, defaults to 5, and applies only to ZIP and 7z. TAR is uncompressed; WIM uses the upstream handler's default settings. Duplicate, unsafe, or conflicting internal paths are rejected. The output is first written to a temporary file beside the destination, then installed atomically; an existing file is preserved unless `overwrite: true` is specified. Archive creation runs in the bounded native worker pool and does not launch a 7-Zip executable.
|
|
155
|
+
|
|
139
156
|
### Extracting to a folder
|
|
140
157
|
|
|
141
158
|
```ts
|
|
142
159
|
await archive.extractToFolder('./unpacked', {
|
|
143
160
|
paths: ['chapter/page1.jpg', 'chapter/page2.jpg'], // alternatively, use ids
|
|
161
|
+
concurrency: 2,
|
|
162
|
+
overwrite: true,
|
|
163
|
+
onProgress: ({filesExtracted, totalFiles, bytesProcessed, totalBytes, percent}) => {
|
|
164
|
+
console.log(`${filesExtracted}/${totalFiles} files, ${percent}% (${bytesProcessed}/${totalBytes} bytes)`);
|
|
165
|
+
},
|
|
166
|
+
onExtract: ({entry, outputPath}) => {
|
|
167
|
+
console.log(`Extracted ${entry.path} to ${outputPath}`);
|
|
168
|
+
},
|
|
144
169
|
maxBytes: 2n * 1024n * 1024n * 1024n,
|
|
145
170
|
signal: abortController.signal,
|
|
146
171
|
});
|
|
147
172
|
```
|
|
148
173
|
|
|
149
|
-
Folder extraction accepts either `ids` or exact `paths`; omit both to extract every entry, and do not provide both together. Paths use archive-internal `/` separators.
|
|
174
|
+
Folder extraction accepts either `ids` or exact, original archive `paths`; omit both to extract every entry, and do not provide both together. Paths use archive-internal `/` separators. `concurrency` sets the maximum number of files extracted simultaneously by this call; it must be an integer from 1 to 128, defaults to 1, and is capped at the process-wide native worker-pool concurrency. It does not change `Archive.concurrency()`. Extraction uses bounded memory and a bounded scheduler rather than queueing one promise per entry. Its default total output limit is 4 GiB; `maxBytes: null` disables that limit. Set `onProgress` to receive the number of regular files selected and fully extracted, the bytes emitted so far across active files, the total uncompressed bytes, and the calculated percentage. It is called before extraction, as data chunks are processed, and after each file completes. `onExtract` is called once per regular file after it is fully written; with `overwrite: true`, it runs after the final replacement. Its `entry` retains the original archive metadata and `outputPath` is the final destination path. On Windows only, invalid filename characters (`< > : | ? * "`) and trailing dots/spaces in each output component are replaced with `_`; selectors and entry metadata remain unchanged. By default, existing files are not overwritten; set `overwrite: true` to replace existing regular files. Replacement is committed only after that entry has been fully extracted, using a temporary file alongside the destination so a failure does not truncate the existing file. This can temporarily require extra disk space up to the size of the entry being replaced. Symlink or non-file targets are rejected. Unsafe archive paths, path collisions, and symlinked destination components are also rejected. Archive links are written as regular file data; the API does not create symbolic or hard links. If extraction fails partway through, files already written (or partially written) may remain in the destination.
|
|
150
175
|
|
|
151
176
|
### Concurrency
|
|
152
177
|
|
|
@@ -182,6 +207,7 @@ Invalid argument types and values throw standard `TypeError` or `RangeError`. Ar
|
|
|
182
207
|
| `ERR_ARCHIVE_PASSWORD` | Password is missing or incorrect when requested by the handler |
|
|
183
208
|
| `ERR_ARCHIVE_PATH` | Unsafe or colliding path during folder extraction |
|
|
184
209
|
| `ERR_ARCHIVE_EXTRACT` | Entry extraction failed |
|
|
210
|
+
| `ERR_ARCHIVE_CREATE` | Archive creation failed or the output already exists |
|
|
185
211
|
|
|
186
212
|
Buffered `extract()` and `read()` do not currently support cancellation. Streams and folder extraction accept an `AbortSignal`.
|
|
187
213
|
|
|
@@ -192,6 +218,7 @@ The package uses the handlers registered in the pinned upstream `Format7zF` bund
|
|
|
192
218
|
### Format-specific notes
|
|
193
219
|
|
|
194
220
|
- **RAR:** extraction uses upstream code subject to its LGPL terms and the unRAR restriction. This package does not create RAR archives or implement the proprietary RAR compression algorithm. Review the upstream notices before redistribution.
|
|
221
|
+
- **Creation:** ZIP, 7z, TAR, and WIM archives can be created from filesystem files. Streams and directory trees are not accepted as sources.
|
|
195
222
|
- **ACE:** not supported by this 7-Zip revision.
|
|
196
223
|
- **Encryption:** password callbacks are connected to archive opening and extraction. AES-256 encrypted ZIP extraction and range reads are tested on Linux x64; encrypted-header behavior and password variants in other formats are not yet verified.
|
|
197
224
|
- **Partial reads:** memory use is limited to the requested range, but the decoder may still process more data internally, especially for solid or sequential formats.
|
|
@@ -200,16 +227,15 @@ See [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) for third-party redistrib
|
|
|
200
227
|
|
|
201
228
|
## Platform support
|
|
202
229
|
|
|
203
|
-
The
|
|
230
|
+
The package includes prebuilt binaries for:
|
|
204
231
|
|
|
205
|
-
| Platform |
|
|
206
|
-
| --- | --- |
|
|
207
|
-
| Linux | x64,
|
|
208
|
-
|
|
|
209
|
-
|
|
|
210
|
-
| Windows | x64, ARM64 | Build and runtime tests |
|
|
232
|
+
| Platform | Architectures |
|
|
233
|
+
| --- | --- |
|
|
234
|
+
| Linux | x64, x86 (ia32), ARM32, ARM64 |
|
|
235
|
+
| macOS | x64, ARM64 |
|
|
236
|
+
| Windows | x64, ARM64 |
|
|
211
237
|
|
|
212
|
-
|
|
238
|
+
Windows x86 (ia32) is not supported by the published prebuilt binaries.
|
|
213
239
|
|
|
214
240
|
## Electron
|
|
215
241
|
|
|
@@ -289,6 +315,17 @@ npm run benchmark:concurrency -- \
|
|
|
289
315
|
|
|
290
316
|
The report includes median and p95 latency, throughput, speedup, and the number of entries that can overlap. `--max-files` selects evenly spaced entries, which is useful when sampling solid archives. Benchmarks report measurements only; results depend on the archive, machine, and workload and do not guarantee a speedup.
|
|
291
317
|
|
|
318
|
+
Compare `extractToFolder()` concurrency (1 through 16) across one or more archives:
|
|
319
|
+
|
|
320
|
+
```sh
|
|
321
|
+
npm run benchmark:folder-concurrency -- \
|
|
322
|
+
--archive "./samples/sample.zip" \
|
|
323
|
+
--archive "./archive.7z" \
|
|
324
|
+
--iterations 1
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Each run extracts one archive into a temporary directory, verifies the completed file and byte counts, and removes the output before continuing. The default is one iteration per concurrency; increase `--iterations` for more stable measurements. The global worker pool is set to 16 for the benchmark and restored afterward.
|
|
328
|
+
|
|
292
329
|
## License
|
|
293
330
|
|
|
294
331
|
This package is MIT-licensed. The integrated 7-Zip code and other bundled dependencies have their own licensing terms; see [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) before redistribution.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {mkdtemp, rm, stat} from 'node:fs/promises';
|
|
2
|
+
import {tmpdir} from 'node:os';
|
|
3
|
+
import {join, resolve} from 'node:path';
|
|
4
|
+
import {Archive, type ExtractToFolderProgress} from '../dist/index.mjs';
|
|
5
|
+
|
|
6
|
+
const usage = 'npm run benchmark:folder-concurrency -- --archive <path> [--archive <path> ...] [--iterations <count>]';
|
|
7
|
+
const archivePaths: string[] = [];
|
|
8
|
+
let iterations = 1;
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
for(let index = 0; index < args.length; index++)
|
|
11
|
+
{
|
|
12
|
+
const argument = args[index]!;
|
|
13
|
+
if(argument === '--archive' || argument.startsWith('--archive='))
|
|
14
|
+
{
|
|
15
|
+
const equalsIndex = argument.indexOf('=');
|
|
16
|
+
const path = equalsIndex === -1 ? args[++index] : argument.slice(equalsIndex + 1);
|
|
17
|
+
if(!path || path.startsWith('--'))
|
|
18
|
+
{
|
|
19
|
+
throw new TypeError(`Missing value for --archive\nUsage: ${usage}`);
|
|
20
|
+
}
|
|
21
|
+
archivePaths.push(resolve(path));
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if(argument === '--iterations' || argument.startsWith('--iterations='))
|
|
25
|
+
{
|
|
26
|
+
const equalsIndex = argument.indexOf('=');
|
|
27
|
+
const value = equalsIndex === -1 ? args[++index] : argument.slice(equalsIndex + 1);
|
|
28
|
+
if(!value || value.startsWith('--'))
|
|
29
|
+
{
|
|
30
|
+
throw new TypeError(`Missing value for --iterations\nUsage: ${usage}`);
|
|
31
|
+
}
|
|
32
|
+
iterations = Number(value);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
throw new TypeError(`Unknown argument ${argument}\nUsage: ${usage}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if(archivePaths.length === 0)
|
|
39
|
+
{
|
|
40
|
+
throw new TypeError(`Specify at least one --archive\nUsage: ${usage}`);
|
|
41
|
+
}
|
|
42
|
+
if(!Number.isSafeInteger(iterations) || iterations < 1)
|
|
43
|
+
{
|
|
44
|
+
throw new RangeError('--iterations must be a positive integer');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const concurrencies = Array.from({length: 16}, (_, index) => index + 1);
|
|
48
|
+
const originalConcurrency = Archive.concurrency();
|
|
49
|
+
const archiveResults = [];
|
|
50
|
+
try
|
|
51
|
+
{
|
|
52
|
+
Archive.concurrency(16);
|
|
53
|
+
for(const archivePath of archivePaths)
|
|
54
|
+
{
|
|
55
|
+
const archive = await Archive.open(archivePath);
|
|
56
|
+
try
|
|
57
|
+
{
|
|
58
|
+
const archiveSize = (await stat(archivePath)).size;
|
|
59
|
+
const files = (await archive.entries()).filter(entry => !entry.isDirectory);
|
|
60
|
+
if(files.length === 0)
|
|
61
|
+
{
|
|
62
|
+
throw new Error(`Archive contains no regular files: ${archivePath}`);
|
|
63
|
+
}
|
|
64
|
+
const expectedBytes = files.reduce((total, file) => total + file.size, 0n);
|
|
65
|
+
const measurements = [];
|
|
66
|
+
let serialMedian = 0;
|
|
67
|
+
for(const concurrency of concurrencies)
|
|
68
|
+
{
|
|
69
|
+
const durations: number[] = [];
|
|
70
|
+
for(let iteration = 0; iteration < iterations; iteration++)
|
|
71
|
+
{
|
|
72
|
+
const temporaryDirectory = await mkdtemp(join(tmpdir(), '7zip-native-folder-bench-'));
|
|
73
|
+
let finalProgress: ExtractToFolderProgress | undefined;
|
|
74
|
+
let completedFiles = 0;
|
|
75
|
+
try
|
|
76
|
+
{
|
|
77
|
+
const start = process.hrtime.bigint();
|
|
78
|
+
await archive.extractToFolder(join(temporaryDirectory, 'output'), {
|
|
79
|
+
concurrency,
|
|
80
|
+
onProgress: progress => {
|
|
81
|
+
finalProgress = progress;
|
|
82
|
+
},
|
|
83
|
+
onExtract: () => {
|
|
84
|
+
completedFiles++;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
durations.push(Number(process.hrtime.bigint() - start) / 1e6);
|
|
88
|
+
if(!finalProgress
|
|
89
|
+
|| finalProgress.filesExtracted !== files.length
|
|
90
|
+
|| finalProgress.totalFiles !== files.length
|
|
91
|
+
|| finalProgress.bytesProcessed !== expectedBytes
|
|
92
|
+
|| finalProgress.totalBytes !== expectedBytes
|
|
93
|
+
|| completedFiles !== files.length)
|
|
94
|
+
{
|
|
95
|
+
throw new Error(`Extraction verification failed for ${archivePath} at concurrency ${concurrency}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
finally
|
|
99
|
+
{
|
|
100
|
+
await rm(temporaryDirectory, {recursive: true, force: true});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const sorted = [...durations].sort((a, b) => a - b);
|
|
104
|
+
const median = sorted[Math.floor(sorted.length / 2)]!;
|
|
105
|
+
const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1]!;
|
|
106
|
+
if(concurrency === 1)
|
|
107
|
+
{
|
|
108
|
+
serialMedian = median;
|
|
109
|
+
}
|
|
110
|
+
measurements.push({
|
|
111
|
+
concurrency,
|
|
112
|
+
iterations,
|
|
113
|
+
median_ms: median,
|
|
114
|
+
p95_ms: p95,
|
|
115
|
+
throughput_mib_per_second: Number(expectedBytes) / (median / 1000) / 1024 / 1024,
|
|
116
|
+
speedup_vs_1: serialMedian / median,
|
|
117
|
+
runs_ms: durations,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
archiveResults.push({
|
|
121
|
+
archive: archivePath,
|
|
122
|
+
archive_bytes: archiveSize,
|
|
123
|
+
files: files.length,
|
|
124
|
+
uncompressed_bytes: expectedBytes.toString(),
|
|
125
|
+
measurements,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
finally
|
|
129
|
+
{
|
|
130
|
+
await archive.close();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
console.log(JSON.stringify({
|
|
134
|
+
global_pool_concurrency: 16,
|
|
135
|
+
concurrencies,
|
|
136
|
+
iterations,
|
|
137
|
+
archives: archiveResults,
|
|
138
|
+
}, null, 2));
|
|
139
|
+
}
|
|
140
|
+
finally
|
|
141
|
+
{
|
|
142
|
+
Archive.concurrency(originalConcurrency);
|
|
143
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var node_fs = require('node:fs');
|
|
4
|
-
var promises
|
|
4
|
+
var promises = require('node:fs/promises');
|
|
5
|
+
var node_crypto = require('node:crypto');
|
|
5
6
|
var node_module = require('node:module');
|
|
6
7
|
var node_os = require('node:os');
|
|
7
8
|
var node_path = require('node:path');
|
|
8
9
|
var node_stream = require('node:stream');
|
|
9
|
-
var promises = require('node:stream/promises');
|
|
10
|
+
var promises$1 = require('node:stream/promises');
|
|
10
11
|
var node_url = require('node:url');
|
|
11
12
|
|
|
12
13
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
@@ -75,10 +76,32 @@ function safeEntrySegments(entryPath) {
|
|
|
75
76
|
}
|
|
76
77
|
return segments;
|
|
77
78
|
}
|
|
79
|
+
function sanitizeOutputPath(entryPath) {
|
|
80
|
+
if (process.platform !== 'win32') {
|
|
81
|
+
return entryPath;
|
|
82
|
+
}
|
|
83
|
+
return entryPath
|
|
84
|
+
.replace(/([. ]+)([\\/]|$)/g, (_match, unsupported, separator) => '_'.repeat(unsupported.length) + separator)
|
|
85
|
+
.replace(/[<>:|?*"]/g, '_');
|
|
86
|
+
}
|
|
87
|
+
function assertSafeRelativeArchivePath(entryPath) {
|
|
88
|
+
const normalized = entryPath.replace(/\\/g, '/');
|
|
89
|
+
const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
|
|
90
|
+
if (entryPath.includes('\0')
|
|
91
|
+
|| normalized.startsWith('/')
|
|
92
|
+
|| /^[a-zA-Z]:/.test(normalized)
|
|
93
|
+
|| segments.length === 0
|
|
94
|
+
|| segments.some(segment => segment === '..')) {
|
|
95
|
+
throw new ArchiveError('ERR_ARCHIVE_PATH', `Unsafe archive entry path: ${entryPath}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
78
98
|
function makeOutputPlans(root, entries) {
|
|
79
99
|
const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
|
|
80
100
|
const plans = entries.map((entry) => {
|
|
81
|
-
|
|
101
|
+
if (process.platform === 'win32') {
|
|
102
|
+
assertSafeRelativeArchivePath(entry.path);
|
|
103
|
+
}
|
|
104
|
+
const segments = safeEntrySegments(sanitizeOutputPath(entry.path));
|
|
82
105
|
const targetPath = node_path.resolve(root, ...segments);
|
|
83
106
|
const relative = node_path.relative(root, targetPath);
|
|
84
107
|
if (relative === ''
|
|
@@ -112,21 +135,65 @@ function makeOutputPlans(root, entries) {
|
|
|
112
135
|
}
|
|
113
136
|
async function ensureDirectory(directoryPath) {
|
|
114
137
|
try {
|
|
115
|
-
await promises
|
|
138
|
+
await promises.mkdir(directoryPath, { mode: 0o700 });
|
|
116
139
|
}
|
|
117
140
|
catch (error) {
|
|
118
141
|
if (error.code !== 'EEXIST') {
|
|
119
142
|
throw error;
|
|
120
143
|
}
|
|
121
144
|
}
|
|
122
|
-
const details = await promises
|
|
145
|
+
const details = await promises.lstat(directoryPath);
|
|
123
146
|
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
124
147
|
throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination path is not a real directory: ${directoryPath}`);
|
|
125
148
|
}
|
|
126
149
|
}
|
|
150
|
+
async function assertReplaceableOutput(filePath) {
|
|
151
|
+
try {
|
|
152
|
+
const details = await promises.lstat(filePath);
|
|
153
|
+
if (!details.isFile()) {
|
|
154
|
+
throw new ArchiveError('ERR_ARCHIVE_PATH', `Cannot replace a non-file extraction target: ${filePath}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error instanceof ArchiveError || error.code !== 'ENOENT') {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function assertOutputDoesNotExist(filePath) {
|
|
164
|
+
try {
|
|
165
|
+
await promises.lstat(filePath);
|
|
166
|
+
throw new ArchiveError('ERR_ARCHIVE_CREATE', `Output archive already exists: ${filePath}`);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof ArchiveError || error.code !== 'ENOENT') {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function normalizeCreateEntry(entry) {
|
|
175
|
+
if (entry === null || typeof entry !== 'object'
|
|
176
|
+
|| typeof entry.sourcePath !== 'string' || entry.sourcePath.length === 0
|
|
177
|
+
|| typeof entry.path !== 'string' || entry.path.length === 0) {
|
|
178
|
+
throw new TypeError('Each archive creation entry must have non-empty sourcePath and path strings');
|
|
179
|
+
}
|
|
180
|
+
const internalPath = safeEntrySegments(entry.path).join('/');
|
|
181
|
+
const sourcePath = node_path.resolve(entry.sourcePath);
|
|
182
|
+
let details;
|
|
183
|
+
try {
|
|
184
|
+
details = await promises.lstat(sourcePath);
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
throw new ArchiveError('ERR_ARCHIVE_CREATE', `Unable to access archive creation source: ${sourcePath}`, { cause });
|
|
188
|
+
}
|
|
189
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
190
|
+
throw new ArchiveError('ERR_ARCHIVE_CREATE', `Archive creation source is not a regular file: ${sourcePath}`);
|
|
191
|
+
}
|
|
192
|
+
return { sourcePath, path: internalPath };
|
|
193
|
+
}
|
|
127
194
|
async function ensureExtractionRoot(root) {
|
|
128
195
|
try {
|
|
129
|
-
const details = await promises
|
|
196
|
+
const details = await promises.lstat(root);
|
|
130
197
|
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
131
198
|
throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination is not a real directory: ${root}`);
|
|
132
199
|
}
|
|
@@ -137,8 +204,8 @@ async function ensureExtractionRoot(root) {
|
|
|
137
204
|
throw error;
|
|
138
205
|
}
|
|
139
206
|
}
|
|
140
|
-
await promises
|
|
141
|
-
const details = await promises
|
|
207
|
+
await promises.mkdir(root, { recursive: true, mode: 0o700 });
|
|
208
|
+
const details = await promises.lstat(root);
|
|
142
209
|
if (details.isSymbolicLink() || !details.isDirectory()) {
|
|
143
210
|
throw new ArchiveError('ERR_ARCHIVE_PATH', `Destination is not a real directory: ${root}`);
|
|
144
211
|
}
|
|
@@ -285,6 +352,99 @@ class Archive {
|
|
|
285
352
|
throw new ArchiveError(/password/i.test(message) ? 'ERR_ARCHIVE_PASSWORD' : 'ERR_ARCHIVE_OPEN', message, { cause });
|
|
286
353
|
}
|
|
287
354
|
}
|
|
355
|
+
static async create(outputPath, entries, options = {}) {
|
|
356
|
+
if (typeof outputPath !== 'string' || outputPath.length === 0) {
|
|
357
|
+
throw new TypeError('Output archive path must be a non-empty string');
|
|
358
|
+
}
|
|
359
|
+
if (!Array.isArray(entries)) {
|
|
360
|
+
throw new TypeError('Archive creation entries must be an array');
|
|
361
|
+
}
|
|
362
|
+
if (entries.length > 1_000_000) {
|
|
363
|
+
throw new RangeError('Archive creation exceeds the 1,000,000-entry limit');
|
|
364
|
+
}
|
|
365
|
+
if (options === null || typeof options !== 'object') {
|
|
366
|
+
throw new TypeError('Archive creation options must be an object');
|
|
367
|
+
}
|
|
368
|
+
if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
|
|
369
|
+
throw new TypeError('Archive creation overwrite option must be a boolean');
|
|
370
|
+
}
|
|
371
|
+
const compressionLevel = options.compressionLevel ?? 5;
|
|
372
|
+
if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
|
|
373
|
+
throw new RangeError('Compression level must be an integer between 0 and 9');
|
|
374
|
+
}
|
|
375
|
+
const targetPath = node_path.resolve(outputPath);
|
|
376
|
+
let format;
|
|
377
|
+
if (options.format !== undefined) {
|
|
378
|
+
if (options.format !== '7z' && options.format !== 'zip'
|
|
379
|
+
&& options.format !== 'tar' && options.format !== 'wim') {
|
|
380
|
+
throw new TypeError('Archive format must be "7z", "zip", "tar", or "wim"');
|
|
381
|
+
}
|
|
382
|
+
format = options.format;
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const extension = node_path.extname(targetPath).toLowerCase();
|
|
386
|
+
if (extension === '.7z') {
|
|
387
|
+
format = '7z';
|
|
388
|
+
}
|
|
389
|
+
else if (extension === '.zip' || extension === '.cbz') {
|
|
390
|
+
format = 'zip';
|
|
391
|
+
}
|
|
392
|
+
else if (extension === '.tar') {
|
|
393
|
+
format = 'tar';
|
|
394
|
+
}
|
|
395
|
+
else if (extension === '.wim') {
|
|
396
|
+
format = 'wim';
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
throw new TypeError('Specify format as "7z", "zip", "tar", or "wim" for an unrecognized output extension');
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (options.compressionLevel !== undefined && format !== '7z' && format !== 'zip') {
|
|
403
|
+
throw new TypeError('compressionLevel is only supported for ZIP and 7z archives');
|
|
404
|
+
}
|
|
405
|
+
const normalizedEntries = await Promise.all(entries.map(normalizeCreateEntry));
|
|
406
|
+
const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
|
|
407
|
+
const pathKeys = normalizedEntries.map(entry => caseInsensitivePaths ? entry.path.toLowerCase() : entry.path);
|
|
408
|
+
const archivePaths = new Set(pathKeys);
|
|
409
|
+
if (archivePaths.size !== normalizedEntries.length) {
|
|
410
|
+
throw new ArchiveError('ERR_ARCHIVE_PATH', 'Archive creation paths must be unique');
|
|
411
|
+
}
|
|
412
|
+
for (const entry of normalizedEntries) {
|
|
413
|
+
const segments = entry.path.split('/');
|
|
414
|
+
for (let index = 1; index < segments.length; index++) {
|
|
415
|
+
const parent = segments.slice(0, index).join('/');
|
|
416
|
+
const parentKey = caseInsensitivePaths ? parent.toLowerCase() : parent;
|
|
417
|
+
if (archivePaths.has(parentKey)) {
|
|
418
|
+
throw new ArchiveError('ERR_ARCHIVE_PATH', `Archive creation file path is a parent of another entry: ${parent}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (options.overwrite) {
|
|
423
|
+
await assertReplaceableOutput(targetPath);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
await assertOutputDoesNotExist(targetPath);
|
|
427
|
+
}
|
|
428
|
+
const temporaryPath = node_path.join(node_path.dirname(targetPath), `.7zip-native-${node_crypto.randomUUID()}.tmp`);
|
|
429
|
+
try {
|
|
430
|
+
await native.create(temporaryPath, format, normalizedEntries, compressionLevel, enginePath);
|
|
431
|
+
if (options.overwrite) {
|
|
432
|
+
await assertReplaceableOutput(targetPath);
|
|
433
|
+
await promises.rename(temporaryPath, targetPath);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
await promises.link(temporaryPath, targetPath);
|
|
437
|
+
await promises.rm(temporaryPath);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (cause) {
|
|
441
|
+
await promises.rm(temporaryPath, { force: true });
|
|
442
|
+
if (cause instanceof ArchiveError) {
|
|
443
|
+
throw cause;
|
|
444
|
+
}
|
|
445
|
+
throw new ArchiveError('ERR_ARCHIVE_CREATE', errorMessage(cause), { cause });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
288
448
|
async entries() {
|
|
289
449
|
this.assertOpen();
|
|
290
450
|
this.entriesPromise ??= native.entries(this.handle).catch((cause) => {
|
|
@@ -355,7 +515,7 @@ class Archive {
|
|
|
355
515
|
if (!destination || typeof destination.write !== 'function') {
|
|
356
516
|
throw new TypeError('Destination must be a writable stream');
|
|
357
517
|
}
|
|
358
|
-
await promises.pipeline(this.createReadStream(idOrPath, options), destination, { signal: options.signal });
|
|
518
|
+
await promises$1.pipeline(this.createReadStream(idOrPath, options), destination, { signal: options.signal });
|
|
359
519
|
}
|
|
360
520
|
async extractToFolder(destination, options = {}) {
|
|
361
521
|
this.assertOpen();
|
|
@@ -365,6 +525,22 @@ class Archive {
|
|
|
365
525
|
if (!options || typeof options !== 'object') {
|
|
366
526
|
throw new TypeError('Folder extraction options must be an object');
|
|
367
527
|
}
|
|
528
|
+
if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
|
|
529
|
+
throw new TypeError('Folder extraction overwrite option must be a boolean');
|
|
530
|
+
}
|
|
531
|
+
if (options.concurrency !== undefined
|
|
532
|
+
&& (!Number.isSafeInteger(options.concurrency)
|
|
533
|
+
|| options.concurrency < 1
|
|
534
|
+
|| options.concurrency > 128)) {
|
|
535
|
+
throw new RangeError('Folder extraction concurrency must be an integer between 1 and 128');
|
|
536
|
+
}
|
|
537
|
+
if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
|
|
538
|
+
throw new TypeError('Folder extraction onProgress option must be a function');
|
|
539
|
+
}
|
|
540
|
+
if (options.onExtract !== undefined && typeof options.onExtract !== 'function') {
|
|
541
|
+
throw new TypeError('Folder extraction onExtract option must be a function');
|
|
542
|
+
}
|
|
543
|
+
const concurrency = Math.min(options.concurrency ?? 1, Archive.concurrency());
|
|
368
544
|
options.signal?.throwIfAborted();
|
|
369
545
|
const maxBytes = normalizeMaxBytes(options.maxBytes, 'maxBytes');
|
|
370
546
|
const entries = await this.entries();
|
|
@@ -410,14 +586,53 @@ class Archive {
|
|
|
410
586
|
options.signal?.throwIfAborted();
|
|
411
587
|
await ensureExtractionRoot(root);
|
|
412
588
|
let extractedBytes = 0n;
|
|
589
|
+
let filesExtracted = 0;
|
|
590
|
+
const totalFiles = plans.filter(plan => !plan.isDirectory).length;
|
|
591
|
+
const reportProgress = () => {
|
|
592
|
+
let percent = 0;
|
|
593
|
+
if (expectedBytes > 0n) {
|
|
594
|
+
const scaledPercent = extractedBytes * 10000n / expectedBytes;
|
|
595
|
+
percent = Number(scaledPercent > 9900n ? 9900n : scaledPercent) / 100;
|
|
596
|
+
}
|
|
597
|
+
if (filesExtracted === totalFiles) {
|
|
598
|
+
percent = 100;
|
|
599
|
+
}
|
|
600
|
+
options.onProgress?.({
|
|
601
|
+
filesExtracted,
|
|
602
|
+
totalFiles,
|
|
603
|
+
bytesProcessed: extractedBytes,
|
|
604
|
+
totalBytes: expectedBytes,
|
|
605
|
+
percent,
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
reportProgress();
|
|
413
609
|
for (const plan of plans) {
|
|
414
610
|
options.signal?.throwIfAborted();
|
|
415
611
|
if (plan.isDirectory) {
|
|
416
612
|
await ensureOutputDirectoryTree(root, plan.targetPath);
|
|
417
|
-
continue;
|
|
418
613
|
}
|
|
614
|
+
}
|
|
615
|
+
const filePlans = plans.filter(plan => !plan.isDirectory);
|
|
616
|
+
const controller = new AbortController();
|
|
617
|
+
const extractionSignal = controller.signal;
|
|
618
|
+
let hasFailure = false;
|
|
619
|
+
let failure;
|
|
620
|
+
let nextFileIndex = 0;
|
|
621
|
+
const stop = (error) => {
|
|
622
|
+
if (!hasFailure) {
|
|
623
|
+
hasFailure = true;
|
|
624
|
+
failure = error;
|
|
625
|
+
}
|
|
626
|
+
if (!extractionSignal.aborted) {
|
|
627
|
+
controller.abort(error);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
const abortFromCaller = () => {
|
|
631
|
+
stop(options.signal?.reason);
|
|
632
|
+
};
|
|
633
|
+
options.signal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
634
|
+
const extractFile = async (plan) => {
|
|
419
635
|
await ensureOutputParents(root, plan.targetPath);
|
|
420
|
-
const output = node_fs.createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 });
|
|
421
636
|
const limit = new node_stream.Transform({
|
|
422
637
|
transform(chunk, _encoding, callback) {
|
|
423
638
|
const size = BigInt(chunk.length);
|
|
@@ -426,13 +641,73 @@ class Archive {
|
|
|
426
641
|
return;
|
|
427
642
|
}
|
|
428
643
|
extractedBytes += size;
|
|
644
|
+
try {
|
|
645
|
+
reportProgress();
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
429
651
|
callback(null, chunk);
|
|
430
652
|
},
|
|
431
653
|
});
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
654
|
+
if (options.overwrite) {
|
|
655
|
+
await assertReplaceableOutput(plan.targetPath);
|
|
656
|
+
const temporaryPath = node_path.join(node_path.dirname(plan.targetPath), `.7zip-native-${node_crypto.randomUUID()}.tmp`);
|
|
657
|
+
try {
|
|
658
|
+
await promises$1.pipeline(this.createReadStream(plan.entry.id, {
|
|
659
|
+
signal: extractionSignal,
|
|
660
|
+
maxBytes: null,
|
|
661
|
+
}), limit, node_fs.createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
|
|
662
|
+
extractionSignal.throwIfAborted();
|
|
663
|
+
await assertReplaceableOutput(plan.targetPath);
|
|
664
|
+
extractionSignal.throwIfAborted();
|
|
665
|
+
await promises.rename(temporaryPath, plan.targetPath);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
await promises.rm(temporaryPath, { force: true });
|
|
669
|
+
throw error;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else {
|
|
673
|
+
await promises$1.pipeline(this.createReadStream(plan.entry.id, {
|
|
674
|
+
signal: extractionSignal,
|
|
675
|
+
maxBytes: null,
|
|
676
|
+
}), limit, node_fs.createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
|
|
677
|
+
}
|
|
678
|
+
filesExtracted++;
|
|
679
|
+
reportProgress();
|
|
680
|
+
options.onExtract?.({ entry: plan.entry, outputPath: plan.targetPath });
|
|
681
|
+
};
|
|
682
|
+
const runWorker = async () => {
|
|
683
|
+
while (!hasFailure) {
|
|
684
|
+
if (options.signal?.aborted) {
|
|
685
|
+
stop(options.signal.reason);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const plan = filePlans[nextFileIndex++];
|
|
689
|
+
if (!plan) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
await extractFile(plan);
|
|
694
|
+
}
|
|
695
|
+
catch (error) {
|
|
696
|
+
stop(error);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
try {
|
|
702
|
+
options.signal?.throwIfAborted();
|
|
703
|
+
const workers = Array.from({ length: Math.min(concurrency, filePlans.length) }, () => runWorker());
|
|
704
|
+
await Promise.all(workers);
|
|
705
|
+
if (hasFailure) {
|
|
706
|
+
throw failure;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
options.signal?.removeEventListener('abort', abortFromCaller);
|
|
436
711
|
}
|
|
437
712
|
}
|
|
438
713
|
async read(idOrPath, options) {
|