7zip-native 0.0.6 → 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/dist/index.d.mts CHANGED
@@ -19,6 +19,16 @@ interface ReadOptions {
19
19
  interface OpenOptions {
20
20
  readonly password?: string;
21
21
  }
22
+ type ArchiveFormat = '7z' | 'zip' | 'tar' | 'wim';
23
+ interface ArchiveCreateEntry {
24
+ readonly sourcePath: string;
25
+ readonly path: string;
26
+ }
27
+ interface CreateArchiveOptions {
28
+ readonly format?: ArchiveFormat;
29
+ readonly compressionLevel?: number;
30
+ readonly overwrite?: boolean;
31
+ }
22
32
  interface ExtractToFolderProgress {
23
33
  readonly filesExtracted: number;
24
34
  readonly totalFiles: number;
@@ -26,6 +36,10 @@ interface ExtractToFolderProgress {
26
36
  readonly totalBytes: bigint;
27
37
  readonly percent: number;
28
38
  }
39
+ interface ExtractedArchiveFile {
40
+ readonly entry: ArchiveEntry;
41
+ readonly outputPath: string;
42
+ }
29
43
  interface ReadStreamOptions {
30
44
  readonly highWaterMark?: number;
31
45
  readonly maxBytes?: number | bigint | null;
@@ -34,12 +48,14 @@ interface ReadStreamOptions {
34
48
  interface ExtractToFolderOptions {
35
49
  readonly ids?: readonly EntryId[];
36
50
  readonly paths?: readonly string[];
51
+ readonly concurrency?: number;
37
52
  readonly overwrite?: boolean;
38
53
  readonly onProgress?: (progress: ExtractToFolderProgress) => void;
54
+ readonly onExtract?: (result: ExtractedArchiveFile) => void;
39
55
  readonly maxBytes?: number | bigint | null;
40
56
  readonly signal?: AbortSignal;
41
57
  }
42
- type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT';
58
+ type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT' | 'ERR_ARCHIVE_CREATE';
43
59
  declare class ArchiveError extends Error {
44
60
  readonly code: ArchiveErrorCode;
45
61
  constructor(code: ArchiveErrorCode, message: string, options?: ErrorOptions);
@@ -52,6 +68,7 @@ declare class Archive {
52
68
  private constructor();
53
69
  static concurrency(value?: number): number;
54
70
  static open(path: string, options?: OpenOptions): Promise<Archive>;
71
+ static create(outputPath: string, entries: readonly ArchiveCreateEntry[], options?: CreateArchiveOptions): Promise<void>;
55
72
  entries(): Promise<readonly ArchiveEntry[]>;
56
73
  getEntry(idOrPath: EntrySelector): Promise<ArchiveEntry | undefined>;
57
74
  extract(idOrPath: EntrySelector): Promise<Buffer>;
@@ -69,4 +86,4 @@ declare class Archive {
69
86
  }
70
87
 
71
88
  export { Archive, ArchiveError };
72
- export type { ArchiveEntry, ArchiveErrorCode, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, OpenOptions, ReadOptions, ReadStreamOptions };
89
+ export type { ArchiveCreateEntry, ArchiveEntry, ArchiveErrorCode, ArchiveFormat, CreateArchiveOptions, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, ExtractedArchiveFile, OpenOptions, ReadOptions, ReadStreamOptions };
package/dist/index.d.ts CHANGED
@@ -19,6 +19,16 @@ interface ReadOptions {
19
19
  interface OpenOptions {
20
20
  readonly password?: string;
21
21
  }
22
+ type ArchiveFormat = '7z' | 'zip' | 'tar' | 'wim';
23
+ interface ArchiveCreateEntry {
24
+ readonly sourcePath: string;
25
+ readonly path: string;
26
+ }
27
+ interface CreateArchiveOptions {
28
+ readonly format?: ArchiveFormat;
29
+ readonly compressionLevel?: number;
30
+ readonly overwrite?: boolean;
31
+ }
22
32
  interface ExtractToFolderProgress {
23
33
  readonly filesExtracted: number;
24
34
  readonly totalFiles: number;
@@ -26,6 +36,10 @@ interface ExtractToFolderProgress {
26
36
  readonly totalBytes: bigint;
27
37
  readonly percent: number;
28
38
  }
39
+ interface ExtractedArchiveFile {
40
+ readonly entry: ArchiveEntry;
41
+ readonly outputPath: string;
42
+ }
29
43
  interface ReadStreamOptions {
30
44
  readonly highWaterMark?: number;
31
45
  readonly maxBytes?: number | bigint | null;
@@ -34,12 +48,14 @@ interface ReadStreamOptions {
34
48
  interface ExtractToFolderOptions {
35
49
  readonly ids?: readonly EntryId[];
36
50
  readonly paths?: readonly string[];
51
+ readonly concurrency?: number;
37
52
  readonly overwrite?: boolean;
38
53
  readonly onProgress?: (progress: ExtractToFolderProgress) => void;
54
+ readonly onExtract?: (result: ExtractedArchiveFile) => void;
39
55
  readonly maxBytes?: number | bigint | null;
40
56
  readonly signal?: AbortSignal;
41
57
  }
42
- type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT';
58
+ type ArchiveErrorCode = 'ERR_ARCHIVE_CLOSED' | 'ERR_INVALID_ENTRY' | 'ERR_INVALID_RANGE' | 'ERR_RESOURCE_LIMIT' | 'ERR_ARCHIVE_OPEN' | 'ERR_ARCHIVE_PASSWORD' | 'ERR_ARCHIVE_PATH' | 'ERR_ARCHIVE_EXTRACT' | 'ERR_ARCHIVE_CREATE';
43
59
  declare class ArchiveError extends Error {
44
60
  readonly code: ArchiveErrorCode;
45
61
  constructor(code: ArchiveErrorCode, message: string, options?: ErrorOptions);
@@ -52,6 +68,7 @@ declare class Archive {
52
68
  private constructor();
53
69
  static concurrency(value?: number): number;
54
70
  static open(path: string, options?: OpenOptions): Promise<Archive>;
71
+ static create(outputPath: string, entries: readonly ArchiveCreateEntry[], options?: CreateArchiveOptions): Promise<void>;
55
72
  entries(): Promise<readonly ArchiveEntry[]>;
56
73
  getEntry(idOrPath: EntrySelector): Promise<ArchiveEntry | undefined>;
57
74
  extract(idOrPath: EntrySelector): Promise<Buffer>;
@@ -69,4 +86,4 @@ declare class Archive {
69
86
  }
70
87
 
71
88
  export { Archive, ArchiveError };
72
- export type { ArchiveEntry, ArchiveErrorCode, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, OpenOptions, ReadOptions, ReadStreamOptions };
89
+ export type { ArchiveCreateEntry, ArchiveEntry, ArchiveErrorCode, ArchiveFormat, CreateArchiveOptions, EntryId, EntrySelector, ExtractToFolderOptions, ExtractToFolderProgress, ExtractedArchiveFile, OpenOptions, ReadOptions, ReadStreamOptions };
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, createWriteStream } from 'node:fs';
2
- import { rename, rm, lstat, mkdir } from 'node:fs/promises';
2
+ import { rename, link, rm, lstat, mkdir } from 'node:fs/promises';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { createRequire } from 'node:module';
5
5
  import { availableParallelism } from 'node:os';
6
- import { dirname, resolve, join, relative, sep } from 'node:path';
7
- import { Transform, Readable } from 'node:stream';
6
+ import { dirname, resolve, join, extname, relative, sep } from 'node:path';
7
+ import { Readable, Transform } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
@@ -73,10 +73,32 @@ function safeEntrySegments(entryPath) {
73
73
  }
74
74
  return segments;
75
75
  }
76
+ function sanitizeOutputPath(entryPath) {
77
+ if (process.platform !== 'win32') {
78
+ return entryPath;
79
+ }
80
+ return entryPath
81
+ .replace(/([. ]+)([\\/]|$)/g, (_match, unsupported, separator) => '_'.repeat(unsupported.length) + separator)
82
+ .replace(/[<>:|?*"]/g, '_');
83
+ }
84
+ function assertSafeRelativeArchivePath(entryPath) {
85
+ const normalized = entryPath.replace(/\\/g, '/');
86
+ const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
87
+ if (entryPath.includes('\0')
88
+ || normalized.startsWith('/')
89
+ || /^[a-zA-Z]:/.test(normalized)
90
+ || segments.length === 0
91
+ || segments.some(segment => segment === '..')) {
92
+ throw new ArchiveError('ERR_ARCHIVE_PATH', `Unsafe archive entry path: ${entryPath}`);
93
+ }
94
+ }
76
95
  function makeOutputPlans(root, entries) {
77
96
  const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
78
97
  const plans = entries.map((entry) => {
79
- const segments = safeEntrySegments(entry.path);
98
+ if (process.platform === 'win32') {
99
+ assertSafeRelativeArchivePath(entry.path);
100
+ }
101
+ const segments = safeEntrySegments(sanitizeOutputPath(entry.path));
80
102
  const targetPath = resolve(root, ...segments);
81
103
  const relative$1 = relative(root, targetPath);
82
104
  if (relative$1 === ''
@@ -135,6 +157,37 @@ async function assertReplaceableOutput(filePath) {
135
157
  }
136
158
  }
137
159
  }
160
+ async function assertOutputDoesNotExist(filePath) {
161
+ try {
162
+ await lstat(filePath);
163
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Output archive already exists: ${filePath}`);
164
+ }
165
+ catch (error) {
166
+ if (error instanceof ArchiveError || error.code !== 'ENOENT') {
167
+ throw error;
168
+ }
169
+ }
170
+ }
171
+ async function normalizeCreateEntry(entry) {
172
+ if (entry === null || typeof entry !== 'object'
173
+ || typeof entry.sourcePath !== 'string' || entry.sourcePath.length === 0
174
+ || typeof entry.path !== 'string' || entry.path.length === 0) {
175
+ throw new TypeError('Each archive creation entry must have non-empty sourcePath and path strings');
176
+ }
177
+ const internalPath = safeEntrySegments(entry.path).join('/');
178
+ const sourcePath = resolve(entry.sourcePath);
179
+ let details;
180
+ try {
181
+ details = await lstat(sourcePath);
182
+ }
183
+ catch (cause) {
184
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Unable to access archive creation source: ${sourcePath}`, { cause });
185
+ }
186
+ if (details.isSymbolicLink() || !details.isFile()) {
187
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', `Archive creation source is not a regular file: ${sourcePath}`);
188
+ }
189
+ return { sourcePath, path: internalPath };
190
+ }
138
191
  async function ensureExtractionRoot(root) {
139
192
  try {
140
193
  const details = await lstat(root);
@@ -296,6 +349,99 @@ class Archive {
296
349
  throw new ArchiveError(/password/i.test(message) ? 'ERR_ARCHIVE_PASSWORD' : 'ERR_ARCHIVE_OPEN', message, { cause });
297
350
  }
298
351
  }
352
+ static async create(outputPath, entries, options = {}) {
353
+ if (typeof outputPath !== 'string' || outputPath.length === 0) {
354
+ throw new TypeError('Output archive path must be a non-empty string');
355
+ }
356
+ if (!Array.isArray(entries)) {
357
+ throw new TypeError('Archive creation entries must be an array');
358
+ }
359
+ if (entries.length > 1_000_000) {
360
+ throw new RangeError('Archive creation exceeds the 1,000,000-entry limit');
361
+ }
362
+ if (options === null || typeof options !== 'object') {
363
+ throw new TypeError('Archive creation options must be an object');
364
+ }
365
+ if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
366
+ throw new TypeError('Archive creation overwrite option must be a boolean');
367
+ }
368
+ const compressionLevel = options.compressionLevel ?? 5;
369
+ if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 0 || compressionLevel > 9) {
370
+ throw new RangeError('Compression level must be an integer between 0 and 9');
371
+ }
372
+ const targetPath = resolve(outputPath);
373
+ let format;
374
+ if (options.format !== undefined) {
375
+ if (options.format !== '7z' && options.format !== 'zip'
376
+ && options.format !== 'tar' && options.format !== 'wim') {
377
+ throw new TypeError('Archive format must be "7z", "zip", "tar", or "wim"');
378
+ }
379
+ format = options.format;
380
+ }
381
+ else {
382
+ const extension = extname(targetPath).toLowerCase();
383
+ if (extension === '.7z') {
384
+ format = '7z';
385
+ }
386
+ else if (extension === '.zip' || extension === '.cbz') {
387
+ format = 'zip';
388
+ }
389
+ else if (extension === '.tar') {
390
+ format = 'tar';
391
+ }
392
+ else if (extension === '.wim') {
393
+ format = 'wim';
394
+ }
395
+ else {
396
+ throw new TypeError('Specify format as "7z", "zip", "tar", or "wim" for an unrecognized output extension');
397
+ }
398
+ }
399
+ if (options.compressionLevel !== undefined && format !== '7z' && format !== 'zip') {
400
+ throw new TypeError('compressionLevel is only supported for ZIP and 7z archives');
401
+ }
402
+ const normalizedEntries = await Promise.all(entries.map(normalizeCreateEntry));
403
+ const caseInsensitivePaths = process.platform === 'win32' || process.platform === 'darwin';
404
+ const pathKeys = normalizedEntries.map(entry => caseInsensitivePaths ? entry.path.toLowerCase() : entry.path);
405
+ const archivePaths = new Set(pathKeys);
406
+ if (archivePaths.size !== normalizedEntries.length) {
407
+ throw new ArchiveError('ERR_ARCHIVE_PATH', 'Archive creation paths must be unique');
408
+ }
409
+ for (const entry of normalizedEntries) {
410
+ const segments = entry.path.split('/');
411
+ for (let index = 1; index < segments.length; index++) {
412
+ const parent = segments.slice(0, index).join('/');
413
+ const parentKey = caseInsensitivePaths ? parent.toLowerCase() : parent;
414
+ if (archivePaths.has(parentKey)) {
415
+ throw new ArchiveError('ERR_ARCHIVE_PATH', `Archive creation file path is a parent of another entry: ${parent}`);
416
+ }
417
+ }
418
+ }
419
+ if (options.overwrite) {
420
+ await assertReplaceableOutput(targetPath);
421
+ }
422
+ else {
423
+ await assertOutputDoesNotExist(targetPath);
424
+ }
425
+ const temporaryPath = join(dirname(targetPath), `.7zip-native-${randomUUID()}.tmp`);
426
+ try {
427
+ await native.create(temporaryPath, format, normalizedEntries, compressionLevel, enginePath);
428
+ if (options.overwrite) {
429
+ await assertReplaceableOutput(targetPath);
430
+ await rename(temporaryPath, targetPath);
431
+ }
432
+ else {
433
+ await link(temporaryPath, targetPath);
434
+ await rm(temporaryPath);
435
+ }
436
+ }
437
+ catch (cause) {
438
+ await rm(temporaryPath, { force: true });
439
+ if (cause instanceof ArchiveError) {
440
+ throw cause;
441
+ }
442
+ throw new ArchiveError('ERR_ARCHIVE_CREATE', errorMessage(cause), { cause });
443
+ }
444
+ }
299
445
  async entries() {
300
446
  this.assertOpen();
301
447
  this.entriesPromise ??= native.entries(this.handle).catch((cause) => {
@@ -379,9 +525,19 @@ class Archive {
379
525
  if (options.overwrite !== undefined && typeof options.overwrite !== 'boolean') {
380
526
  throw new TypeError('Folder extraction overwrite option must be a boolean');
381
527
  }
528
+ if (options.concurrency !== undefined
529
+ && (!Number.isSafeInteger(options.concurrency)
530
+ || options.concurrency < 1
531
+ || options.concurrency > 128)) {
532
+ throw new RangeError('Folder extraction concurrency must be an integer between 1 and 128');
533
+ }
382
534
  if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
383
535
  throw new TypeError('Folder extraction onProgress option must be a function');
384
536
  }
537
+ if (options.onExtract !== undefined && typeof options.onExtract !== 'function') {
538
+ throw new TypeError('Folder extraction onExtract option must be a function');
539
+ }
540
+ const concurrency = Math.min(options.concurrency ?? 1, Archive.concurrency());
385
541
  options.signal?.throwIfAborted();
386
542
  const maxBytes = normalizeMaxBytes(options.maxBytes, 'maxBytes');
387
543
  const entries = await this.entries();
@@ -451,8 +607,28 @@ class Archive {
451
607
  options.signal?.throwIfAborted();
452
608
  if (plan.isDirectory) {
453
609
  await ensureOutputDirectoryTree(root, plan.targetPath);
454
- continue;
455
610
  }
611
+ }
612
+ const filePlans = plans.filter(plan => !plan.isDirectory);
613
+ const controller = new AbortController();
614
+ const extractionSignal = controller.signal;
615
+ let hasFailure = false;
616
+ let failure;
617
+ let nextFileIndex = 0;
618
+ const stop = (error) => {
619
+ if (!hasFailure) {
620
+ hasFailure = true;
621
+ failure = error;
622
+ }
623
+ if (!extractionSignal.aborted) {
624
+ controller.abort(error);
625
+ }
626
+ };
627
+ const abortFromCaller = () => {
628
+ stop(options.signal?.reason);
629
+ };
630
+ options.signal?.addEventListener('abort', abortFromCaller, { once: true });
631
+ const extractFile = async (plan) => {
456
632
  await ensureOutputParents(root, plan.targetPath);
457
633
  const limit = new Transform({
458
634
  transform(chunk, _encoding, callback) {
@@ -477,10 +653,12 @@ class Archive {
477
653
  const temporaryPath = join(dirname(plan.targetPath), `.7zip-native-${randomUUID()}.tmp`);
478
654
  try {
479
655
  await pipeline(this.createReadStream(plan.entry.id, {
480
- signal: options.signal,
656
+ signal: extractionSignal,
481
657
  maxBytes: null,
482
- }), limit, createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
658
+ }), limit, createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
659
+ extractionSignal.throwIfAborted();
483
660
  await assertReplaceableOutput(plan.targetPath);
661
+ extractionSignal.throwIfAborted();
484
662
  await rename(temporaryPath, plan.targetPath);
485
663
  }
486
664
  catch (error) {
@@ -490,12 +668,43 @@ class Archive {
490
668
  }
491
669
  else {
492
670
  await pipeline(this.createReadStream(plan.entry.id, {
493
- signal: options.signal,
671
+ signal: extractionSignal,
494
672
  maxBytes: null,
495
- }), limit, createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
673
+ }), limit, createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
496
674
  }
497
675
  filesExtracted++;
498
676
  reportProgress();
677
+ options.onExtract?.({ entry: plan.entry, outputPath: plan.targetPath });
678
+ };
679
+ const runWorker = async () => {
680
+ while (!hasFailure) {
681
+ if (options.signal?.aborted) {
682
+ stop(options.signal.reason);
683
+ return;
684
+ }
685
+ const plan = filePlans[nextFileIndex++];
686
+ if (!plan) {
687
+ return;
688
+ }
689
+ try {
690
+ await extractFile(plan);
691
+ }
692
+ catch (error) {
693
+ stop(error);
694
+ return;
695
+ }
696
+ }
697
+ };
698
+ try {
699
+ options.signal?.throwIfAborted();
700
+ const workers = Array.from({ length: Math.min(concurrency, filePlans.length) }, () => runWorker());
701
+ await Promise.all(workers);
702
+ if (hasFailure) {
703
+ throw failure;
704
+ }
705
+ }
706
+ finally {
707
+ options.signal?.removeEventListener('abort', abortFromCaller);
499
708
  }
500
709
  }
501
710
  async read(idOrPath, options) {