exarch-rs 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs CHANGED
@@ -44,6 +44,8 @@
44
44
  #![allow(clippy::trailing_empty_array)]
45
45
 
46
46
  use napi::bindgen_prelude::*;
47
+ use napi::threadsafe_function::ThreadsafeFunction;
48
+ use napi::threadsafe_function::ThreadsafeFunctionCallMode;
47
49
  use napi_derive::napi;
48
50
 
49
51
  mod config;
@@ -52,6 +54,7 @@ mod report;
52
54
  mod utils;
53
55
 
54
56
  use config::CreationConfig;
57
+ use config::ExtractionOptions;
55
58
  use config::SecurityConfig;
56
59
  use error::convert_error;
57
60
  use report::ArchiveManifest;
@@ -117,8 +120,12 @@ use utils::validate_path;
117
120
  /// console.log(`Extracted ${report.filesExtracted} files`);
118
121
  ///
119
122
  /// // Customize security settings
120
- /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
123
+ /// const config = new SecurityConfig().setMaxFileSize(100 * 1024 * 1024);
121
124
  /// const report = await extractArchive('archive.tar.gz', '/tmp/output', config);
125
+ ///
126
+ /// // Customize extraction options
127
+ /// const opts = new ExtractionOptions().withSkipDuplicates(false);
128
+ /// const report = await extractArchive('archive.tar.gz', '/tmp/output', null, opts);
122
129
  /// ```
123
130
  #[napi]
124
131
  #[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
@@ -126,6 +133,7 @@ pub async fn extract_archive(
126
133
  archive_path: String,
127
134
  output_dir: String,
128
135
  config: Option<&SecurityConfig>,
136
+ options: Option<&ExtractionOptions>,
129
137
  ) -> Result<ExtractionReport> {
130
138
  // Validate paths at boundary
131
139
  // NOTE: Defense-in-depth - paths are validated here and again in core
@@ -134,9 +142,11 @@ pub async fn extract_archive(
134
142
  validate_path(&archive_path)?;
135
143
  validate_path(&output_dir)?;
136
144
 
137
- // Get owned config - clone only when config is Some, use default otherwise
145
+ // Get owned config/options - clone only when Some, use default otherwise
138
146
  let config_owned: exarch_core::SecurityConfig =
139
147
  config.map(|c| c.as_core().clone()).unwrap_or_default();
148
+ let options_owned: exarch_core::ExtractionOptions =
149
+ options.map(|o| o.as_core().clone()).unwrap_or_default();
140
150
 
141
151
  // Run extraction on tokio thread pool
142
152
  //
@@ -150,11 +160,21 @@ pub async fn extract_archive(
150
160
  // For maximum security with untrusted archives, use extractArchiveSync()
151
161
  // or ensure exclusive file access (e.g., flock) during extraction.
152
162
  let report = tokio::task::spawn_blocking(move || {
153
- exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
163
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
164
+ exarch_core::extract_archive_with_options(
165
+ &archive_path,
166
+ &output_dir,
167
+ &config_owned,
168
+ &options_owned,
169
+ )
170
+ .map_err(convert_error)
171
+ }))
172
+ .map_err(|_| Error::from_reason("Internal panic during archive extraction"))
173
+ .flatten()
154
174
  })
155
175
  .await
156
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
157
- .map_err(convert_error)?;
176
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
177
+ .flatten()?;
158
178
 
159
179
  Ok(ExtractionReport::from(report))
160
180
  }
@@ -187,7 +207,7 @@ pub async fn extract_archive(
187
207
  /// console.log(`Extracted ${report.filesExtracted} files`);
188
208
  ///
189
209
  /// // Customize security settings
190
- /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
210
+ /// const config = new SecurityConfig().setMaxFileSize(100 * 1024 * 1024);
191
211
  /// const report = extractArchiveSync('archive.tar.gz', '/tmp/output', config);
192
212
  /// ```
193
213
  #[napi]
@@ -196,6 +216,7 @@ pub fn extract_archive_sync(
196
216
  archive_path: String,
197
217
  output_dir: String,
198
218
  config: Option<&SecurityConfig>,
219
+ options: Option<&ExtractionOptions>,
199
220
  ) -> Result<ExtractionReport> {
200
221
  // Validate paths at boundary
201
222
  // NOTE: Defense-in-depth - paths are validated here and again in core
@@ -204,14 +225,21 @@ pub fn extract_archive_sync(
204
225
  validate_path(&archive_path)?;
205
226
  validate_path(&output_dir)?;
206
227
 
207
- // Get config reference or use default
208
228
  let default_config = exarch_core::SecurityConfig::default();
209
229
  let config_ref = config.map_or(&default_config, |c| c.as_core());
210
230
 
231
+ let default_options = exarch_core::ExtractionOptions::default();
232
+ let options_ref = options.map_or(&default_options, |o| o.as_core());
233
+
211
234
  // Run extraction synchronously with panic safety
212
235
  // CRITICAL: Never panic across FFI boundary
213
236
  let report = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
214
- exarch_core::extract_archive(&archive_path, &output_dir, config_ref)
237
+ exarch_core::extract_archive_with_options(
238
+ &archive_path,
239
+ &output_dir,
240
+ config_ref,
241
+ options_ref,
242
+ )
215
243
  }))
216
244
  .map_err(|_| Error::from_reason("Internal panic during archive extraction"))?
217
245
  .map_err(convert_error)?;
@@ -264,12 +292,17 @@ pub async fn create_archive(
264
292
  config.map(|c| c.as_core().clone()).unwrap_or_default();
265
293
 
266
294
  let report = tokio::task::spawn_blocking(move || {
267
- let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
268
- exarch_core::create_archive(&output_path, &sources_refs, &config_owned)
295
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
296
+ let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
297
+ exarch_core::create_archive(&output_path, &sources_refs, &config_owned)
298
+ .map_err(convert_error)
299
+ }))
300
+ .map_err(|_| Error::from_reason("Internal panic during archive creation"))
301
+ .flatten()
269
302
  })
270
303
  .await
271
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
272
- .map_err(convert_error)?;
304
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
305
+ .flatten()?;
273
306
 
274
307
  Ok(CreationReport::from(report))
275
308
  }
@@ -364,11 +397,15 @@ pub async fn list_archive(
364
397
  config.map(|c| c.as_core().clone()).unwrap_or_default();
365
398
 
366
399
  let manifest = tokio::task::spawn_blocking(move || {
367
- exarch_core::list_archive(&archive_path, &config_owned)
400
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
401
+ exarch_core::list_archive(&archive_path, &config_owned).map_err(convert_error)
402
+ }))
403
+ .map_err(|_| Error::from_reason("Internal panic during archive listing"))
404
+ .flatten()
368
405
  })
369
406
  .await
370
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
371
- .map_err(convert_error)?;
407
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
408
+ .flatten()?;
372
409
 
373
410
  Ok(ArchiveManifest::from(manifest))
374
411
  }
@@ -461,11 +498,15 @@ pub async fn verify_archive(
461
498
  config.map(|c| c.as_core().clone()).unwrap_or_default();
462
499
 
463
500
  let report = tokio::task::spawn_blocking(move || {
464
- exarch_core::verify_archive(&archive_path, &config_owned)
501
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
502
+ exarch_core::verify_archive(&archive_path, &config_owned).map_err(convert_error)
503
+ }))
504
+ .map_err(|_| Error::from_reason("Internal panic during archive verification"))
505
+ .flatten()
465
506
  })
466
507
  .await
467
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
468
- .map_err(convert_error)?;
508
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
509
+ .flatten()?;
469
510
 
470
511
  Ok(VerificationReport::from(report))
471
512
  }
@@ -517,10 +558,170 @@ pub fn verify_archive_sync(
517
558
  Ok(VerificationReport::from(report))
518
559
  }
519
560
 
520
- // NOTE: Progress callback support (createArchiveWithProgress) is planned for
521
- // a future release. The napi-rs 3.x ThreadsafeFunction API requires additional
522
- // work to properly bridge Rust ProgressCallback trait to JavaScript callbacks.
523
- // For now, use createArchive/createArchiveSync without progress tracking.
561
+ /// Extract an archive to the specified directory with a progress callback
562
+ /// (async).
563
+ ///
564
+ /// The `progress` callback is called once per entry with
565
+ /// `(path, total, current, bytesWritten)` where:
566
+ /// - `path` — entry path inside the archive
567
+ /// - `total` — total number of entries as `number` (0 for TAR-family formats
568
+ /// because the entry count is unknown until the stream is fully read)
569
+ /// - `current` — 1-based index of the current entry as `number`
570
+ /// - `bytesWritten` — cumulative bytes written to disk so far as `number`
571
+ /// (always 0 during extraction because the core library does not emit
572
+ /// byte-level progress events for extraction; only entry-level events fire)
573
+ ///
574
+ /// Extraction runs on the tokio blocking thread pool. The progress callback is
575
+ /// dispatched back to the JavaScript thread via a threadsafe function.
576
+ ///
577
+ /// # Arguments
578
+ ///
579
+ /// * `archive_path` - Path to the archive file
580
+ /// * `output_dir` - Directory where files will be extracted
581
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
582
+ /// * `options` - Optional `ExtractionOptions` (uses defaults if omitted)
583
+ /// * `progress` - Optional progress callback `(path: string, total: number,
584
+ /// current: number, bytesWritten: number) => void`
585
+ ///
586
+ /// # Returns
587
+ ///
588
+ /// Promise resolving to `ExtractionReport` with extraction statistics
589
+ ///
590
+ /// # Errors
591
+ ///
592
+ /// Returns error for security violations or I/O errors. Error messages are
593
+ /// prefixed with error codes for discrimination in JavaScript. See
594
+ /// `extractArchive` for the full list of error codes.
595
+ ///
596
+ /// # Examples
597
+ ///
598
+ /// ```javascript
599
+ /// const report = await extractArchiveWithProgress(
600
+ /// 'archive.tar.gz',
601
+ /// '/tmp/output',
602
+ /// null,
603
+ /// null,
604
+ /// (path, total, current, bytesWritten) => {
605
+ /// console.log(`${current}/${total}: ${path}`);
606
+ /// },
607
+ /// );
608
+ /// console.log(`Extracted ${report.filesExtracted} files`);
609
+ /// ```
610
+ #[napi]
611
+ #[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
612
+ pub async fn extract_archive_with_progress(
613
+ archive_path: String,
614
+ output_dir: String,
615
+ config: Option<&SecurityConfig>,
616
+ options: Option<&ExtractionOptions>,
617
+ progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
618
+ ) -> Result<ExtractionReport> {
619
+ validate_path(&archive_path)?;
620
+ validate_path(&output_dir)?;
621
+
622
+ let config_owned: exarch_core::SecurityConfig =
623
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
624
+ let options_owned: exarch_core::ExtractionOptions =
625
+ options.map(|o| o.as_core().clone()).unwrap_or_default();
626
+
627
+ let report = tokio::task::spawn_blocking(move || {
628
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
629
+ run_extract_with_optional_progress(
630
+ &archive_path,
631
+ &output_dir,
632
+ &config_owned,
633
+ &options_owned,
634
+ progress,
635
+ )
636
+ .map_err(convert_error)
637
+ }))
638
+ .map_err(|_| Error::from_reason("Internal panic during archive extraction with progress"))
639
+ .flatten()
640
+ })
641
+ .await
642
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
643
+ .flatten()?;
644
+
645
+ Ok(ExtractionReport::from(report))
646
+ }
647
+
648
+ /// Runs `extract_archive_with_options_and_progress` routing to the JS callback
649
+ /// when present or to [`exarch_core::NoopProgress`] when absent.
650
+ fn run_extract_with_optional_progress(
651
+ archive_path: &str,
652
+ output_dir: &str,
653
+ config: &exarch_core::SecurityConfig,
654
+ options: &exarch_core::ExtractionOptions,
655
+ progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
656
+ ) -> exarch_core::Result<exarch_core::ExtractionReport> {
657
+ progress.map_or_else(
658
+ || {
659
+ let mut noop = exarch_core::NoopProgress;
660
+ exarch_core::extract_archive_with_options_and_progress(
661
+ archive_path,
662
+ output_dir,
663
+ config,
664
+ options,
665
+ &mut noop,
666
+ )
667
+ },
668
+ |tsfn| {
669
+ let mut callback = NodeProgressAdapter::new(tsfn);
670
+ exarch_core::extract_archive_with_options_and_progress(
671
+ archive_path,
672
+ output_dir,
673
+ config,
674
+ options,
675
+ &mut callback,
676
+ )
677
+ },
678
+ )
679
+ }
680
+
681
+ /// Adapter that calls a JavaScript progress callback from a Rust worker thread.
682
+ ///
683
+ /// The JavaScript callback receives `(path: string, total: number, current:
684
+ /// number, bytesWritten: number)` where `bytesWritten` is the number of bytes
685
+ /// written **for the current entry so far** (starts at 0 when the entry begins,
686
+ /// grows as chunks are flushed to disk; always 0 during extraction because the
687
+ /// core library does not emit byte-level progress events for extraction).
688
+ struct NodeProgressAdapter {
689
+ tsfn: ThreadsafeFunction<(String, i64, i64, i64)>,
690
+ current_entry_bytes: i64,
691
+ total: usize,
692
+ }
693
+
694
+ impl NodeProgressAdapter {
695
+ fn new(tsfn: ThreadsafeFunction<(String, i64, i64, i64)>) -> Self {
696
+ Self {
697
+ tsfn,
698
+ current_entry_bytes: 0,
699
+ total: 0,
700
+ }
701
+ }
702
+ }
703
+
704
+ impl exarch_core::ProgressCallback for NodeProgressAdapter {
705
+ fn on_entry_start(&mut self, path: &std::path::Path, total: usize, current: usize) {
706
+ self.current_entry_bytes = 0;
707
+ self.total = total;
708
+ let path_str = path.to_string_lossy().into_owned();
709
+ let total_i64 = i64::try_from(total).unwrap_or(i64::MAX);
710
+ let current_i64 = i64::try_from(current).unwrap_or(i64::MAX);
711
+ self.tsfn.call(
712
+ Ok((path_str, total_i64, current_i64, self.current_entry_bytes)),
713
+ ThreadsafeFunctionCallMode::NonBlocking,
714
+ );
715
+ }
716
+
717
+ fn on_bytes_written(&mut self, bytes: u64) {
718
+ self.current_entry_bytes = self.current_entry_bytes.saturating_add(bytes.cast_signed());
719
+ }
720
+
721
+ fn on_entry_complete(&mut self, _path: &std::path::Path) {}
722
+
723
+ fn on_complete(&mut self) {}
724
+ }
524
725
 
525
726
  #[cfg(test)]
526
727
  #[allow(
@@ -539,6 +740,7 @@ mod tests {
539
740
  "/tmp/test\0malicious.tar".to_string(),
540
741
  "/tmp/output".to_string(),
541
742
  None,
743
+ None,
542
744
  )
543
745
  .await;
544
746
 
@@ -555,6 +757,7 @@ mod tests {
555
757
  "/tmp/test.tar".to_string(),
556
758
  "/tmp/output\0malicious".to_string(),
557
759
  None,
760
+ None,
558
761
  )
559
762
  .await;
560
763
 
@@ -568,7 +771,7 @@ mod tests {
568
771
  #[tokio::test]
569
772
  async fn test_extract_archive_rejects_excessively_long_archive_path() {
570
773
  let long_path = "x".repeat(5000);
571
- let result = extract_archive(long_path, "/tmp/output".to_string(), None).await;
774
+ let result = extract_archive(long_path, "/tmp/output".to_string(), None, None).await;
572
775
 
573
776
  assert!(result.is_err(), "should reject excessively long paths");
574
777
  assert!(
@@ -580,7 +783,7 @@ mod tests {
580
783
  #[tokio::test]
581
784
  async fn test_extract_archive_rejects_excessively_long_output_dir() {
582
785
  let long_path = "x".repeat(5000);
583
- let result = extract_archive("/tmp/test.tar".to_string(), long_path, None).await;
786
+ let result = extract_archive("/tmp/test.tar".to_string(), long_path, None, None).await;
584
787
 
585
788
  assert!(result.is_err(), "should reject excessively long paths");
586
789
  assert!(
@@ -593,7 +796,7 @@ mod tests {
593
796
  async fn test_extract_archive_accepts_empty_paths() {
594
797
  // Empty paths should be accepted at boundary validation
595
798
  // Core library will handle actual path validation
596
- let result = extract_archive("".to_string(), "".to_string(), None).await;
799
+ let result = extract_archive("".to_string(), "".to_string(), None, None).await;
597
800
 
598
801
  // If it fails, ensure it's not a boundary path validation error
599
802
  // (empty paths pass boundary validation; core handles semantic validation)
@@ -615,6 +818,7 @@ mod tests {
615
818
  "/tmp/test\0malicious.tar".to_string(),
616
819
  "/tmp/output".to_string(),
617
820
  None,
821
+ None,
618
822
  );
619
823
 
620
824
  assert!(result.is_err(), "should reject null bytes in archive path");
@@ -630,6 +834,7 @@ mod tests {
630
834
  "/tmp/test.tar".to_string(),
631
835
  "/tmp/output\0malicious".to_string(),
632
836
  None,
837
+ None,
633
838
  );
634
839
 
635
840
  assert!(result.is_err(), "should reject null bytes in output path");
@@ -642,7 +847,7 @@ mod tests {
642
847
  #[test]
643
848
  fn test_extract_archive_sync_rejects_excessively_long_archive_path() {
644
849
  let long_path = "x".repeat(5000);
645
- let result = extract_archive_sync(long_path, "/tmp/output".to_string(), None);
850
+ let result = extract_archive_sync(long_path, "/tmp/output".to_string(), None, None);
646
851
 
647
852
  assert!(result.is_err(), "should reject excessively long paths");
648
853
  assert!(
@@ -654,7 +859,7 @@ mod tests {
654
859
  #[test]
655
860
  fn test_extract_archive_sync_rejects_excessively_long_output_dir() {
656
861
  let long_path = "x".repeat(5000);
657
- let result = extract_archive_sync("/tmp/test.tar".to_string(), long_path, None);
862
+ let result = extract_archive_sync("/tmp/test.tar".to_string(), long_path, None, None);
658
863
 
659
864
  assert!(result.is_err(), "should reject excessively long paths");
660
865
  assert!(
@@ -672,6 +877,7 @@ mod tests {
672
877
  "/tmp/valid_test_path.tar".to_string(),
673
878
  "/tmp/valid_output_path".to_string(),
674
879
  None,
880
+ None,
675
881
  );
676
882
 
677
883
  // If it fails, ensure it's not a path validation error
@@ -693,6 +899,7 @@ mod tests {
693
899
  "relative_test.tar".to_string(),
694
900
  "relative_output".to_string(),
695
901
  None,
902
+ None,
696
903
  );
697
904
 
698
905
  // If it fails, ensure it's not a path validation error
@@ -717,6 +924,7 @@ mod tests {
717
924
  "custom_test.tar".to_string(),
718
925
  "custom_output".to_string(),
719
926
  Some(&config),
927
+ None,
720
928
  );
721
929
 
722
930
  // If it fails, ensure it's not a path validation error
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tests for ExtractionOptions class
3
+ */
4
+ const { describe, it, beforeEach, afterEach } = require('node:test');
5
+ const assert = require('node:assert');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const os = require('node:os');
9
+ const { ExtractionOptions, extractArchiveSync, createArchiveSync } = require('../index.js');
10
+
11
+ function createTempDir() {
12
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'exarch-test-'));
13
+ }
14
+
15
+ function createValidArchive(archivePath, tempDir) {
16
+ const sourceDir = path.join(tempDir, 'source');
17
+ fs.mkdirSync(sourceDir);
18
+ fs.writeFileSync(path.join(sourceDir, 'hello.txt'), 'Hello, World!');
19
+ createArchiveSync(archivePath, [sourceDir]);
20
+ }
21
+
22
+ describe('ExtractionOptions', () => {
23
+ describe('constructor', () => {
24
+ it('should create options with skipDuplicates defaulting to true', () => {
25
+ const opts = new ExtractionOptions();
26
+ assert.strictEqual(opts.skipDuplicates, true);
27
+ });
28
+ });
29
+
30
+ describe('static default()', () => {
31
+ it('should return options equivalent to constructor', () => {
32
+ const opts = ExtractionOptions.default();
33
+ assert.strictEqual(opts.skipDuplicates, true);
34
+ });
35
+ });
36
+
37
+ describe('withSkipDuplicates()', () => {
38
+ it('should set skipDuplicates to false', () => {
39
+ const opts = new ExtractionOptions();
40
+ opts.withSkipDuplicates(false);
41
+ assert.strictEqual(opts.skipDuplicates, false);
42
+ });
43
+
44
+ it('should set skipDuplicates to true', () => {
45
+ const opts = new ExtractionOptions();
46
+ opts.withSkipDuplicates(false);
47
+ opts.withSkipDuplicates(true);
48
+ assert.strictEqual(opts.skipDuplicates, true);
49
+ });
50
+
51
+ it('should default to true when called with no argument', () => {
52
+ const opts = new ExtractionOptions();
53
+ opts.withSkipDuplicates(false);
54
+ opts.withSkipDuplicates();
55
+ assert.strictEqual(opts.skipDuplicates, true);
56
+ });
57
+ });
58
+
59
+ describe('build()', () => {
60
+ it('should return self for builder consistency', () => {
61
+ const opts = new ExtractionOptions();
62
+ const result = opts.build();
63
+ assert.strictEqual(result, opts);
64
+ });
65
+ });
66
+
67
+ describe('withAtomic()', () => {
68
+ it('should have atomic defaulting to false', () => {
69
+ const opts = new ExtractionOptions();
70
+ assert.strictEqual(opts.atomic, false);
71
+ });
72
+
73
+ it('should set atomic to true via withAtomic', () => {
74
+ const opts = new ExtractionOptions();
75
+ opts.withAtomic(true);
76
+ assert.strictEqual(opts.atomic, true);
77
+ });
78
+ });
79
+
80
+ describe('withSkipDuplicates() round-trip', () => {
81
+ it('should have skipDuplicates defaulting to true', () => {
82
+ const opts = new ExtractionOptions();
83
+ assert.strictEqual(opts.skipDuplicates, true);
84
+ });
85
+
86
+ it('should set skipDuplicates to false via withSkipDuplicates', () => {
87
+ const opts = new ExtractionOptions();
88
+ opts.withSkipDuplicates(false);
89
+ assert.strictEqual(opts.skipDuplicates, false);
90
+ });
91
+ });
92
+ });
93
+
94
+ describe('extractArchiveSync with ExtractionOptions', () => {
95
+ let tempDir;
96
+ let archivePath;
97
+ let outputDir;
98
+
99
+ beforeEach(() => {
100
+ tempDir = createTempDir();
101
+ archivePath = path.join(tempDir, 'test.tar.gz');
102
+ outputDir = path.join(tempDir, 'output');
103
+ fs.mkdirSync(outputDir);
104
+ createValidArchive(archivePath, tempDir);
105
+ });
106
+
107
+ afterEach(() => {
108
+ if (tempDir && fs.existsSync(tempDir)) {
109
+ fs.rmSync(tempDir, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ it('should extract with default ExtractionOptions', () => {
114
+ const opts = new ExtractionOptions();
115
+ const report = extractArchiveSync(archivePath, outputDir, null, opts);
116
+
117
+ assert.strictEqual(report.filesExtracted, 1);
118
+ assert.ok(report.bytesWritten >= 13);
119
+ });
120
+
121
+ it('should extract with skip_duplicates=false on non-duplicate archive', () => {
122
+ const opts = new ExtractionOptions();
123
+ opts.withSkipDuplicates(false);
124
+ const report = extractArchiveSync(archivePath, outputDir, null, opts);
125
+
126
+ assert.strictEqual(report.filesExtracted, 1);
127
+ });
128
+
129
+ it('should extract without options (null) as before', () => {
130
+ const report = extractArchiveSync(archivePath, outputDir, null, null);
131
+ assert.strictEqual(report.filesExtracted, 1);
132
+ });
133
+ });
@@ -112,6 +112,19 @@ describe('SecurityConfig', () => {
112
112
  assert.strictEqual(config.allowWorldWritable, true);
113
113
  });
114
114
 
115
+ it('should default allowSolidArchives to false', () => {
116
+ const config = new SecurityConfig();
117
+
118
+ assert.strictEqual(config.allowSolidArchives, false);
119
+ });
120
+
121
+ it('should set allow solid archives', () => {
122
+ const config = new SecurityConfig();
123
+ config.setAllowSolidArchives(true);
124
+
125
+ assert.strictEqual(config.allowSolidArchives, true);
126
+ });
127
+
115
128
  it('should set preserve permissions', () => {
116
129
  const config = new SecurityConfig();
117
130
  config.setPreservePermissions(true);