7zip-native 0.0.8 → 0.0.12

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.
Files changed (34) hide show
  1. package/README.md +46 -14
  2. package/bench/compare.mts +25 -11
  3. package/bench/folder-concurrency.mts +5 -12
  4. package/bench/multiprocess-extract.cjs +334 -0
  5. package/dist/index.cjs +215 -110
  6. package/dist/index.d.cts +10 -9
  7. package/dist/index.d.mts +10 -9
  8. package/dist/index.d.ts +10 -9
  9. package/dist/index.mjs +217 -112
  10. package/native/addon.cpp +1399 -71
  11. package/package.json +4 -2
  12. package/patches/deflate-input-buffer-size.patch +47 -0
  13. package/prebuilds/darwin-arm64/7zip_native.node +0 -0
  14. package/prebuilds/darwin-arm64/lib7z.dylib +0 -0
  15. package/prebuilds/darwin-x64/7zip_native.node +0 -0
  16. package/prebuilds/darwin-x64/lib7z.dylib +0 -0
  17. package/prebuilds/linux-arm/7z.so +0 -0
  18. package/prebuilds/linux-arm/7zip_native.node +0 -0
  19. package/prebuilds/linux-arm64/7z.so +0 -0
  20. package/prebuilds/linux-arm64/7zip_native.node +0 -0
  21. package/prebuilds/linux-ia32/7z.so +0 -0
  22. package/prebuilds/linux-ia32/7zip_native.node +0 -0
  23. package/prebuilds/linux-x64/7z.so +0 -0
  24. package/prebuilds/linux-x64/7zip_native.node +0 -0
  25. package/prebuilds/win32-arm64/7z.dll +0 -0
  26. package/prebuilds/win32-arm64/7zip_native.node +0 -0
  27. package/prebuilds/win32-x64/7z.dll +0 -0
  28. package/prebuilds/win32-x64/7zip_native.node +0 -0
  29. package/scripts/build-native.mjs +22 -1
  30. package/vendor/7zip/CPP/7zip/Archive/Archive2.def +1 -0
  31. package/vendor/7zip/CPP/7zip/Bundles/Format7zF/_o/7z.so +0 -0
  32. package/vendor/7zip/CPP/7zip/Bundles/Format7zF/_o/Alloc.o +0 -0
  33. package/vendor/7zip/CPP/7zip/Bundles/Format7zF/_o/DeflateDecoder.o +0 -0
  34. package/vendor/7zip/CPP/7zip/Compress/DeflateDecoder.cpp +15 -1
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
- import { existsSync, createWriteStream } from 'node:fs';
1
+ import { existsSync } from 'node:fs';
2
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
6
  import { dirname, resolve, join, extname, relative, sep } from 'node:path';
7
- import { Readable, Transform } from 'node:stream';
7
+ import { Readable } from 'node:stream';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
 
10
10
  class ArchiveError extends Error {
@@ -17,6 +17,9 @@ class ArchiveError extends Error {
17
17
  }
18
18
 
19
19
  const defaultMaxOutputBytes = 4n * 1024n * 1024n * 1024n;
20
+ const defaultDeflateBufferSize = 32 * 1024;
21
+ const minDeflateBufferSize = 4 * 1024;
22
+ const maxDeflateBufferSize = 2 * 1024 * 1024;
20
23
  function normalizeMaxBytes(value, label) {
21
24
  if (value === undefined) {
22
25
  return defaultMaxOutputBytes;
@@ -60,32 +63,44 @@ function safeEntrySegments(entryPath) {
60
63
  throw new ArchiveError('ERR_ARCHIVE_PATH', 'Archive entry path contains a null byte');
61
64
  }
62
65
  const normalized = entryPath.replace(/\\/g, '/');
63
- if (normalized.startsWith('/') || /^[a-zA-Z]:/.test(normalized)) {
66
+ if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) {
64
67
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Archive entry path is absolute: ${entryPath}`);
65
68
  }
66
69
  const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
67
- if (segments.length === 0 || segments.some(segment => segment === '..'
68
- || segment.includes(':')
69
- || /[. ]$/.test(segment)
70
- || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(segment))) {
70
+ if (segments.length === 0 || segments.some(segment => segment === '..')) {
71
71
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Unsafe archive entry path: ${entryPath}`);
72
72
  }
73
73
  return segments;
74
74
  }
75
+ function sanitizeWindowsSegment(segment) {
76
+ if (segment === '.' || segment === '..') {
77
+ return segment;
78
+ }
79
+ const sanitized = [...segment].map(character => character.charCodeAt(0) < 0x20 || '<>:|?*"'.includes(character) ? '_' : character).join('')
80
+ .replace(/[. ]+$/g, trailing => '_'.repeat(trailing.length));
81
+ const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])/i.exec(sanitized);
82
+ if (reservedName) {
83
+ let index = reservedName[0].length;
84
+ while (sanitized[index] === ' ') {
85
+ index++;
86
+ }
87
+ if (index === sanitized.length || sanitized[index] === '.') {
88
+ return `_${sanitized}`;
89
+ }
90
+ }
91
+ return sanitized;
92
+ }
75
93
  function sanitizeOutputPath(entryPath) {
76
94
  if (process.platform !== 'win32') {
77
95
  return entryPath;
78
96
  }
79
- return entryPath
80
- .replace(/([. ]+)([\\/]|$)/g, (_match, unsupported, separator) => '_'.repeat(unsupported.length) + separator)
81
- .replace(/[<>:|?*"]/g, '_');
97
+ return entryPath.replace(/\\/g, '/').split('/').map(sanitizeWindowsSegment).join('/');
82
98
  }
83
99
  function assertSafeRelativeArchivePath(entryPath) {
84
100
  const normalized = entryPath.replace(/\\/g, '/');
85
101
  const segments = normalized.split('/').filter(segment => segment !== '' && segment !== '.');
86
- if (entryPath.includes('\0')
87
- || normalized.startsWith('/')
88
- || /^[a-zA-Z]:/.test(normalized)
102
+ if (normalized.startsWith('/')
103
+ || /^[a-zA-Z]:\//.test(normalized)
89
104
  || segments.length === 0
90
105
  || segments.some(segment => segment === '..')) {
91
106
  throw new ArchiveError('ERR_ARCHIVE_PATH', `Unsafe archive entry path: ${entryPath}`);
@@ -156,6 +171,19 @@ async function assertReplaceableOutput(filePath) {
156
171
  }
157
172
  }
158
173
  }
174
+ async function assertFolderOutputDoesNotExist(filePath) {
175
+ try {
176
+ await lstat(filePath);
177
+ const error = new Error(`Output file already exists: ${filePath}`);
178
+ Object.assign(error, { code: 'EEXIST' });
179
+ throw error;
180
+ }
181
+ catch (error) {
182
+ if (error.code !== 'ENOENT') {
183
+ throw error;
184
+ }
185
+ }
186
+ }
159
187
  async function assertOutputDoesNotExist(filePath) {
160
188
  try {
161
189
  await lstat(filePath);
@@ -185,7 +213,7 @@ async function normalizeCreateEntry(entry) {
185
213
  if (details.isSymbolicLink() || !details.isFile()) {
186
214
  throw new ArchiveError('ERR_ARCHIVE_CREATE', `Archive creation source is not a regular file: ${sourcePath}`);
187
215
  }
188
- return { sourcePath, path: internalPath };
216
+ return { sourcePath, path: internalPath, mtimeMs: details.mtimeMs };
189
217
  }
190
218
  async function ensureExtractionRoot(root) {
191
219
  try {
@@ -315,6 +343,7 @@ class Archive {
315
343
  handle;
316
344
  closed = false;
317
345
  entriesPromise;
346
+ entryIndexes;
318
347
  constructor(handle) {
319
348
  this.handle = handle;
320
349
  }
@@ -339,8 +368,14 @@ class Archive {
339
368
  throw new RangeError('Password exceeds the 1 MiB native password limit');
340
369
  }
341
370
  }
371
+ const deflateBufferSize = options.deflateBufferSize ?? defaultDeflateBufferSize;
372
+ if (!Number.isSafeInteger(deflateBufferSize)
373
+ || deflateBufferSize < minDeflateBufferSize
374
+ || deflateBufferSize > maxDeflateBufferSize) {
375
+ throw new RangeError(`Deflate buffer size must be an integer between ${minDeflateBufferSize} and ${maxDeflateBufferSize} bytes`);
376
+ }
342
377
  try {
343
- const handle = await native.open(resolve(path), options.password, enginePath);
378
+ const handle = await native.open(resolve(path), options.password, enginePath, deflateBufferSize);
344
379
  return new Archive(handle);
345
380
  }
346
381
  catch (cause) {
@@ -452,7 +487,23 @@ class Archive {
452
487
  if (typeof idOrPath === 'number' && (!Number.isSafeInteger(idOrPath) || idOrPath < 0)) {
453
488
  throw new RangeError('Entry ID must be a non-negative safe integer');
454
489
  }
455
- return (await this.entries()).find(entry => typeof idOrPath === 'number' ? entry.id === idOrPath : entry.path === idOrPath);
490
+ const entries = await this.entries();
491
+ if (!this.entryIndexes) {
492
+ const pathEntries = Object.create(null);
493
+ const idEntries = Object.create(null);
494
+ for (const entry of entries) {
495
+ pathEntries[entry.path] ??= entry;
496
+ idEntries[entry.id] ??= entry;
497
+ }
498
+ this.entryIndexes = { pathEntries, idEntries };
499
+ }
500
+ if (typeof idOrPath === 'number') {
501
+ return this.entryIndexes.idEntries[idOrPath];
502
+ }
503
+ if (typeof idOrPath === 'string') {
504
+ return this.entryIndexes.pathEntries[idOrPath];
505
+ }
506
+ return undefined;
456
507
  }
457
508
  async extract(idOrPathOrPaths) {
458
509
  this.assertOpen();
@@ -530,9 +581,6 @@ class Archive {
530
581
  || options.concurrency > 128)) {
531
582
  throw new RangeError('Folder extraction concurrency must be an integer between 1 and 128');
532
583
  }
533
- if (options.onProgress !== undefined && typeof options.onProgress !== 'function') {
534
- throw new TypeError('Folder extraction onProgress option must be a function');
535
- }
536
584
  if (options.onExtract !== undefined && typeof options.onExtract !== 'function') {
537
585
  throw new TypeError('Folder extraction onExtract option must be a function');
538
586
  }
@@ -575,33 +623,13 @@ class Archive {
575
623
  }
576
624
  const root = resolve(destination);
577
625
  const plans = makeOutputPlans(root, selectedEntries);
578
- const expectedBytes = selectedEntries.reduce((total, entry) => total + (entry.isDirectory ? 0n : entry.size), 0n);
579
- if (maxBytes !== null && expectedBytes > maxBytes) {
626
+ const totalBytes = selectedEntries.reduce((total, entry) => total + (entry.isDirectory ? 0n : entry.size), 0n);
627
+ if (maxBytes !== null && totalBytes > maxBytes) {
580
628
  throw new ArchiveError('ERR_RESOURCE_LIMIT', `Folder output exceeds the ${maxBytes}-byte limit`);
581
629
  }
582
630
  options.signal?.throwIfAborted();
583
631
  await ensureExtractionRoot(root);
584
- let extractedBytes = 0n;
585
632
  let filesExtracted = 0;
586
- const totalFiles = plans.filter(plan => !plan.isDirectory).length;
587
- const reportProgress = () => {
588
- let percent = 0;
589
- if (expectedBytes > 0n) {
590
- const scaledPercent = extractedBytes * 10000n / expectedBytes;
591
- percent = Number(scaledPercent > 9900n ? 9900n : scaledPercent) / 100;
592
- }
593
- if (filesExtracted === totalFiles) {
594
- percent = 100;
595
- }
596
- options.onProgress?.({
597
- filesExtracted,
598
- totalFiles,
599
- bytesProcessed: extractedBytes,
600
- totalBytes: expectedBytes,
601
- percent,
602
- });
603
- };
604
- reportProgress();
605
633
  for (const plan of plans) {
606
634
  options.signal?.throwIfAborted();
607
635
  if (plan.isDirectory) {
@@ -613,7 +641,6 @@ class Archive {
613
641
  const extractionSignal = controller.signal;
614
642
  let hasFailure = false;
615
643
  let failure;
616
- let nextFileIndex = 0;
617
644
  const stop = (error) => {
618
645
  if (!hasFailure) {
619
646
  hasFailure = true;
@@ -627,78 +654,77 @@ class Archive {
627
654
  stop(options.signal?.reason);
628
655
  };
629
656
  options.signal?.addEventListener('abort', abortFromCaller, { once: true });
630
- const extractFile = async (plan) => {
631
- await ensureOutputParents(root, plan.targetPath);
632
- const limit = new Transform({
633
- transform(chunk, _encoding, callback) {
634
- const size = BigInt(chunk.length);
635
- if (maxBytes !== null && extractedBytes + size > maxBytes) {
636
- callback(new ArchiveError('ERR_RESOURCE_LIMIT', `Folder output exceeds the ${maxBytes}-byte limit`));
637
- return;
638
- }
639
- extractedBytes += size;
640
- try {
641
- reportProgress();
657
+ const groupCount = Math.min(concurrency, filePlans.length);
658
+ const fileGroups = Array.from({ length: groupCount }, () => []);
659
+ filePlans.forEach((plan, index) => {
660
+ fileGroups[index % groupCount].push(plan);
661
+ });
662
+ const extractGroup = async (group) => {
663
+ const outputs = [];
664
+ let completionFailed = false;
665
+ let completionFailure;
666
+ try {
667
+ for (const plan of group) {
668
+ await ensureOutputParents(root, plan.targetPath);
669
+ extractionSignal.throwIfAborted();
670
+ if (options.overwrite) {
671
+ await assertReplaceableOutput(plan.targetPath);
642
672
  }
643
- catch (error) {
644
- callback(error instanceof Error ? error : new Error(String(error)));
645
- return;
673
+ else {
674
+ await assertFolderOutputDoesNotExist(plan.targetPath);
646
675
  }
647
- callback(null, chunk);
648
- },
649
- });
650
- if (options.overwrite) {
651
- await assertReplaceableOutput(plan.targetPath);
652
- const temporaryPath = join(dirname(plan.targetPath), `.7zip-native-${randomUUID()}.tmp`);
653
- try {
654
- await pipeline(this.createReadStream(plan.entry.id, {
655
- signal: extractionSignal,
656
- maxBytes: null,
657
- }), limit, createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
658
- extractionSignal.throwIfAborted();
659
- await assertReplaceableOutput(plan.targetPath);
660
- extractionSignal.throwIfAborted();
661
- await rename(temporaryPath, plan.targetPath);
662
- }
663
- catch (error) {
664
- await rm(temporaryPath, { force: true });
665
- throw error;
676
+ outputs.push({
677
+ id: plan.entry.id,
678
+ path: plan.targetPath,
679
+ overwrite: options.overwrite === true,
680
+ });
666
681
  }
667
- }
668
- else {
669
- await pipeline(this.createReadStream(plan.entry.id, {
670
- signal: extractionSignal,
671
- maxBytes: null,
672
- }), limit, createWriteStream(plan.targetPath, { flags: 'wx', mode: 0o600 }), { signal: extractionSignal });
673
- }
674
- filesExtracted++;
675
- reportProgress();
676
- options.onExtract?.({ entry: plan.entry, outputPath: plan.targetPath });
677
- };
678
- const runWorker = async () => {
679
- while (!hasFailure) {
680
- if (options.signal?.aborted) {
681
- stop(options.signal.reason);
682
- return;
683
- }
684
- const plan = filePlans[nextFileIndex++];
685
- if (!plan) {
686
- return;
687
- }
688
- try {
689
- await extractFile(plan);
682
+ let onFileCompleted;
683
+ const onExtract = options.onExtract;
684
+ if (onExtract) {
685
+ const plansById = new Map(group.map(plan => [plan.entry.id, plan]));
686
+ onFileCompleted = (id) => {
687
+ if (completionFailed || hasFailure) {
688
+ return;
689
+ }
690
+ try {
691
+ const plan = plansById.get(id);
692
+ if (plan === undefined) {
693
+ throw new ArchiveError('ERR_ARCHIVE_EXTRACT', 'Native folder extraction returned an unknown file completion');
694
+ }
695
+ extractionSignal.throwIfAborted();
696
+ filesExtracted++;
697
+ onExtract({
698
+ entry: plan.entry,
699
+ outputPath: plan.targetPath,
700
+ percent: filesExtracted * 100 / filePlans.length,
701
+ });
702
+ }
703
+ catch (error) {
704
+ completionFailed = true;
705
+ completionFailure = error;
706
+ stop(error);
707
+ }
708
+ };
690
709
  }
691
- catch (error) {
692
- stop(error);
693
- return;
710
+ await this.extractEntriesToFiles(outputs, extractionSignal, onFileCompleted);
711
+ if (completionFailed) {
712
+ throw completionFailure;
694
713
  }
695
714
  }
715
+ catch (caughtError) {
716
+ throw completionFailed ? completionFailure : caughtError;
717
+ }
696
718
  };
697
719
  try {
698
720
  options.signal?.throwIfAborted();
699
- const workers = Array.from({ length: Math.min(concurrency, filePlans.length) }, () => runWorker());
700
- await Promise.all(workers);
701
- if (hasFailure) {
721
+ const workers = fileGroups.map(group => extractGroup(group));
722
+ try {
723
+ await Promise.all(workers);
724
+ }
725
+ catch (error) {
726
+ stop(error);
727
+ await Promise.allSettled(workers);
702
728
  throw failure;
703
729
  }
704
730
  }
@@ -706,8 +732,67 @@ class Archive {
706
732
  options.signal?.removeEventListener('abort', abortFromCaller);
707
733
  }
708
734
  }
709
- async read(idOrPath, options) {
735
+ async read(idOrPathOrPaths, options) {
710
736
  this.assertOpen();
737
+ const maxUInt64 = (1n << 64n) - 1n;
738
+ const normalizeRange = (range) => {
739
+ const offset = this.toUnsignedBigInt(range.offset ?? 0, 'offset');
740
+ const length = this.toUnsignedBigInt(range.length, 'length');
741
+ if (offset > maxUInt64 || length > maxUInt64 - offset) {
742
+ throw new ArchiveError('ERR_INVALID_RANGE', 'Read range exceeds the native 64-bit offset limit');
743
+ }
744
+ return { offset, length };
745
+ };
746
+ if (options === undefined) {
747
+ if (!Array.isArray(idOrPathOrPaths)) {
748
+ throw new TypeError('Read options are required for an entry selector');
749
+ }
750
+ const requests = idOrPathOrPaths;
751
+ const normalized = await Promise.all(requests.map(async (request) => {
752
+ if (!request || typeof request !== 'object' || !('entry' in request)) {
753
+ throw new TypeError('Batch read requests must include an entry selector');
754
+ }
755
+ const range = normalizeRange(request);
756
+ return {
757
+ id: await this.resolveEntryId(request.entry),
758
+ ...range,
759
+ };
760
+ }));
761
+ if (!normalized.length) {
762
+ return [];
763
+ }
764
+ try {
765
+ return await native.readMany(this.handle, normalized);
766
+ }
767
+ catch (cause) {
768
+ throw extractionError(cause);
769
+ }
770
+ }
771
+ const isBatch = Array.isArray(idOrPathOrPaths);
772
+ let selectors;
773
+ if (isBatch) {
774
+ selectors = idOrPathOrPaths;
775
+ }
776
+ else {
777
+ selectors = [idOrPathOrPaths];
778
+ }
779
+ const { offset, length } = normalizeRange(options);
780
+ if (isBatch) {
781
+ const ids = await Promise.all(selectors.map(selector => this.resolveEntryId(selector)));
782
+ const uniqueIds = [...new Set(ids)];
783
+ if (!uniqueIds.length) {
784
+ return [];
785
+ }
786
+ try {
787
+ const buffers = await native.readMany(this.handle, uniqueIds.map(id => ({ id, offset, length })));
788
+ const buffersById = new Map(uniqueIds.map((id, index) => [id, buffers[index]]));
789
+ return ids.map(id => buffersById.get(id));
790
+ }
791
+ catch (cause) {
792
+ throw extractionError(cause);
793
+ }
794
+ }
795
+ const idOrPath = selectors[0];
711
796
  let id;
712
797
  if (typeof idOrPath === 'number') {
713
798
  this.validateIds([idOrPath]);
@@ -716,12 +801,6 @@ class Archive {
716
801
  else {
717
802
  id = await this.resolveEntryId(idOrPath);
718
803
  }
719
- const offset = this.toUnsignedBigInt(options.offset ?? 0, 'offset');
720
- const length = this.toUnsignedBigInt(options.length, 'length');
721
- const maxUInt64 = (1n << 64n) - 1n;
722
- if (offset > maxUInt64 || length > maxUInt64 - offset) {
723
- throw new ArchiveError('ERR_INVALID_RANGE', 'Read range exceeds the native 64-bit offset limit');
724
- }
725
804
  try {
726
805
  return await native.read(this.handle, id, offset, length);
727
806
  }
@@ -729,6 +808,32 @@ class Archive {
729
808
  throw extractionError(cause);
730
809
  }
731
810
  }
811
+ async extractEntriesToFiles(outputs, signal, onFileCompleted) {
812
+ let task;
813
+ const cancelTask = () => {
814
+ if (task) {
815
+ native.cancelExtractToFiles(task.taskId);
816
+ }
817
+ };
818
+ try {
819
+ signal.throwIfAborted();
820
+ task = native.startExtractToFiles(this.handle, outputs, onFileCompleted);
821
+ signal.addEventListener('abort', cancelTask, { once: true });
822
+ if (signal.aborted) {
823
+ cancelTask();
824
+ }
825
+ return await task.promise;
826
+ }
827
+ catch (cause) {
828
+ if (signal.aborted) {
829
+ throw signal.reason ?? cause;
830
+ }
831
+ throw extractionError(cause);
832
+ }
833
+ finally {
834
+ signal.removeEventListener('abort', cancelTask);
835
+ }
836
+ }
732
837
  async close() {
733
838
  if (this.closed) {
734
839
  return;