exarch-rs 0.4.1 → 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.
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exarch-rs",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Memory-safe archive extraction library with built-in security validation",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/config.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! Node.js bindings for `SecurityConfig`.
1
+ //! Node.js bindings for `SecurityConfig` and `ExtractionOptions`.
2
2
 
3
3
  use exarch_core::SecurityConfig as CoreConfig;
4
4
  use napi::bindgen_prelude::Error;
@@ -30,7 +30,7 @@ const MAX_COMPONENT_LENGTH: usize = 255;
30
30
  /// | `allow_world_writable` | false |
31
31
  /// | `preserve_permissions` | false |
32
32
  /// | `allowed_extensions` | empty (all allowed) |
33
- /// | `banned_path_components` | `.git`, `.ssh` |
33
+ /// | `banned_path_components` | `.git`, `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `.env` |
34
34
  #[napi]
35
35
  #[derive(Debug, Clone)]
36
36
  pub struct SecurityConfig {
@@ -171,6 +171,29 @@ impl SecurityConfig {
171
171
  self
172
172
  }
173
173
 
174
+ /// Sets the maximum memory that may be used to decompress a solid 7z block.
175
+ ///
176
+ /// Solid archives decompress entire blocks into memory before individual
177
+ /// entries can be read. This limit caps that allocation to prevent
178
+ /// memory exhaustion from crafted archives. Default: 512 MB.
179
+ ///
180
+ /// # Errors
181
+ ///
182
+ /// Returns error if size is negative or zero.
183
+ #[napi(js_name = "setMaxSolidBlockMemory")]
184
+ pub fn set_max_solid_block_memory(&mut self, size: i64) -> Result<&Self> {
185
+ if size <= 0 {
186
+ return Err(Error::from_reason(
187
+ "max solid block memory must be a positive number",
188
+ ));
189
+ }
190
+ #[allow(clippy::cast_sign_loss)]
191
+ {
192
+ self.inner.max_solid_block_memory = size as u64;
193
+ }
194
+ Ok(self)
195
+ }
196
+
174
197
  /// Sets whether to preserve permissions from archive.
175
198
  #[napi(js_name = "setPreservePermissions")]
176
199
  pub fn set_preserve_permissions(&mut self, preserve: Option<bool>) -> &Self {
@@ -319,6 +342,13 @@ impl SecurityConfig {
319
342
  self.inner.allow_solid_archives
320
343
  }
321
344
 
345
+ /// Maximum memory in bytes for decompressing a solid 7z block.
346
+ #[napi(getter)]
347
+ #[allow(clippy::cast_possible_wrap)]
348
+ pub fn get_max_solid_block_memory(&self) -> i64 {
349
+ self.inner.max_solid_block_memory as i64
350
+ }
351
+
322
352
  /// List of allowed file extensions.
323
353
  ///
324
354
  /// Note: This getter clones the underlying data. For performance-critical
@@ -557,6 +587,96 @@ impl CreationConfig {
557
587
  }
558
588
  }
559
589
 
590
+ /// Options controlling extraction behavior (non-security).
591
+ ///
592
+ /// Separate from `SecurityConfig` to keep security settings focused.
593
+ /// These options control operational behavior such as duplicate handling.
594
+ ///
595
+ /// # Defaults
596
+ ///
597
+ /// | Setting | Default Value |
598
+ /// |---------|--------------|
599
+ /// | `skipDuplicates` | `true` |
600
+ /// | `atomic` | `false` |
601
+ #[napi]
602
+ #[derive(Debug, Clone)]
603
+ pub struct ExtractionOptions {
604
+ inner: exarch_core::ExtractionOptions,
605
+ }
606
+
607
+ #[napi]
608
+ impl ExtractionOptions {
609
+ /// Creates a new `ExtractionOptions` with defaults.
610
+ #[napi(constructor)]
611
+ pub fn new() -> Self {
612
+ Self {
613
+ inner: exarch_core::ExtractionOptions::default(),
614
+ }
615
+ }
616
+
617
+ /// Creates an `ExtractionOptions` with defaults.
618
+ ///
619
+ /// This is equivalent to calling `new ExtractionOptions()`.
620
+ #[napi(factory)]
621
+ pub fn default() -> Self {
622
+ Self::new()
623
+ }
624
+
625
+ /// Sets whether duplicate archive entries are skipped silently.
626
+ ///
627
+ /// When `true` (default), duplicate entries produce a warning in the
628
+ /// report. When `false`, a duplicate entry causes an error.
629
+ #[napi(js_name = "withSkipDuplicates")]
630
+ pub fn with_skip_duplicates(&mut self, skip: Option<bool>) -> &Self {
631
+ self.inner.skip_duplicates = skip.unwrap_or(true);
632
+ self
633
+ }
634
+
635
+ /// Sets whether extraction uses a temporary directory for atomic commits.
636
+ ///
637
+ /// When `true`, files are extracted to a temp dir in the same parent as
638
+ /// the output directory, then atomically renamed on completion. On failure
639
+ /// the temp dir is removed, leaving the output directory untouched.
640
+ /// Default: `false`.
641
+ ///
642
+ /// **Important:** atomic mode requires that the output directory does not
643
+ /// already exist. If it does, extraction fails with an
644
+ /// output-already-exists error. Non-atomic mode extracts into an
645
+ /// existing directory without error.
646
+ #[napi(js_name = "withAtomic")]
647
+ pub fn with_atomic(&mut self, atomic: Option<bool>) -> &Self {
648
+ self.inner.atomic = atomic.unwrap_or(true);
649
+ self
650
+ }
651
+
652
+ /// Finalizes the configuration (for API consistency).
653
+ #[napi]
654
+ pub fn build(&self) -> &Self {
655
+ self
656
+ }
657
+
658
+ /// Whether duplicate entries are skipped silently.
659
+ #[napi(getter)]
660
+ pub fn get_skip_duplicates(&self) -> bool {
661
+ self.inner.skip_duplicates
662
+ }
663
+
664
+ /// Whether atomic extraction is enabled.
665
+ #[napi(getter)]
666
+ pub fn get_atomic(&self) -> bool {
667
+ self.inner.atomic
668
+ }
669
+ }
670
+
671
+ impl ExtractionOptions {
672
+ /// Returns a reference to the inner `CoreExtractionOptions`.
673
+ ///
674
+ /// Used internally to pass options to the Rust extraction API.
675
+ pub fn as_core(&self) -> &exarch_core::ExtractionOptions {
676
+ &self.inner
677
+ }
678
+ }
679
+
560
680
  #[cfg(test)]
561
681
  #[allow(
562
682
  clippy::unwrap_used,
@@ -851,6 +971,54 @@ mod tests {
851
971
  );
852
972
  }
853
973
 
974
+ #[test]
975
+ fn test_max_solid_block_memory_default() {
976
+ let config = SecurityConfig::new();
977
+ assert_eq!(
978
+ config.get_max_solid_block_memory(),
979
+ 512 * 1024 * 1024,
980
+ "max_solid_block_memory should default to 512 MB"
981
+ );
982
+ }
983
+
984
+ #[test]
985
+ fn test_max_solid_block_memory_round_trip() {
986
+ let mut config = SecurityConfig::new();
987
+ let result = config.set_max_solid_block_memory(256 * 1024 * 1024);
988
+ assert!(result.is_ok());
989
+ assert_eq!(
990
+ config.get_max_solid_block_memory(),
991
+ 256 * 1024 * 1024,
992
+ "getter should reflect set value"
993
+ );
994
+ }
995
+
996
+ #[test]
997
+ fn test_max_solid_block_memory_rejects_negative() {
998
+ let mut config = SecurityConfig::new();
999
+ let result = config.set_max_solid_block_memory(-1);
1000
+ assert!(result.is_err(), "negative value should be rejected");
1001
+ assert!(
1002
+ result.unwrap_err().to_string().contains("positive"),
1003
+ "error should mention positive requirement"
1004
+ );
1005
+ }
1006
+
1007
+ #[test]
1008
+ fn test_max_solid_block_memory_rejects_zero() {
1009
+ let mut config = SecurityConfig::new();
1010
+ let result = config.set_max_solid_block_memory(0);
1011
+ assert!(result.is_err(), "zero should be rejected");
1012
+ }
1013
+
1014
+ #[test]
1015
+ fn test_max_solid_block_memory_accepts_one_byte() {
1016
+ let mut config = SecurityConfig::new();
1017
+ let result = config.set_max_solid_block_memory(1);
1018
+ assert!(result.is_ok(), "1 byte should be accepted");
1019
+ assert_eq!(config.get_max_solid_block_memory(), 1);
1020
+ }
1021
+
854
1022
  #[test]
855
1023
  fn test_allowed_extensions_getter_after_add() {
856
1024
  let mut config = SecurityConfig::new();
package/src/lib.rs CHANGED
@@ -54,6 +54,7 @@ mod report;
54
54
  mod utils;
55
55
 
56
56
  use config::CreationConfig;
57
+ use config::ExtractionOptions;
57
58
  use config::SecurityConfig;
58
59
  use error::convert_error;
59
60
  use report::ArchiveManifest;
@@ -119,8 +120,12 @@ use utils::validate_path;
119
120
  /// console.log(`Extracted ${report.filesExtracted} files`);
120
121
  ///
121
122
  /// // Customize security settings
122
- /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
123
+ /// const config = new SecurityConfig().setMaxFileSize(100 * 1024 * 1024);
123
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);
124
129
  /// ```
125
130
  #[napi]
126
131
  #[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
@@ -128,6 +133,7 @@ pub async fn extract_archive(
128
133
  archive_path: String,
129
134
  output_dir: String,
130
135
  config: Option<&SecurityConfig>,
136
+ options: Option<&ExtractionOptions>,
131
137
  ) -> Result<ExtractionReport> {
132
138
  // Validate paths at boundary
133
139
  // NOTE: Defense-in-depth - paths are validated here and again in core
@@ -136,9 +142,11 @@ pub async fn extract_archive(
136
142
  validate_path(&archive_path)?;
137
143
  validate_path(&output_dir)?;
138
144
 
139
- // Get owned config - clone only when config is Some, use default otherwise
145
+ // Get owned config/options - clone only when Some, use default otherwise
140
146
  let config_owned: exarch_core::SecurityConfig =
141
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();
142
150
 
143
151
  // Run extraction on tokio thread pool
144
152
  //
@@ -153,8 +161,13 @@ pub async fn extract_archive(
153
161
  // or ensure exclusive file access (e.g., flock) during extraction.
154
162
  let report = tokio::task::spawn_blocking(move || {
155
163
  std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
156
- exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
157
- .map_err(convert_error)
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)
158
171
  }))
159
172
  .map_err(|_| Error::from_reason("Internal panic during archive extraction"))
160
173
  .flatten()
@@ -194,7 +207,7 @@ pub async fn extract_archive(
194
207
  /// console.log(`Extracted ${report.filesExtracted} files`);
195
208
  ///
196
209
  /// // Customize security settings
197
- /// const config = new SecurityConfig().maxFileSize(100 * 1024 * 1024);
210
+ /// const config = new SecurityConfig().setMaxFileSize(100 * 1024 * 1024);
198
211
  /// const report = extractArchiveSync('archive.tar.gz', '/tmp/output', config);
199
212
  /// ```
200
213
  #[napi]
@@ -203,6 +216,7 @@ pub fn extract_archive_sync(
203
216
  archive_path: String,
204
217
  output_dir: String,
205
218
  config: Option<&SecurityConfig>,
219
+ options: Option<&ExtractionOptions>,
206
220
  ) -> Result<ExtractionReport> {
207
221
  // Validate paths at boundary
208
222
  // NOTE: Defense-in-depth - paths are validated here and again in core
@@ -211,14 +225,21 @@ pub fn extract_archive_sync(
211
225
  validate_path(&archive_path)?;
212
226
  validate_path(&output_dir)?;
213
227
 
214
- // Get config reference or use default
215
228
  let default_config = exarch_core::SecurityConfig::default();
216
229
  let config_ref = config.map_or(&default_config, |c| c.as_core());
217
230
 
231
+ let default_options = exarch_core::ExtractionOptions::default();
232
+ let options_ref = options.map_or(&default_options, |o| o.as_core());
233
+
218
234
  // Run extraction synchronously with panic safety
219
235
  // CRITICAL: Never panic across FFI boundary
220
236
  let report = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
221
- 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
+ )
222
243
  }))
223
244
  .map_err(|_| Error::from_reason("Internal panic during archive extraction"))?
224
245
  .map_err(convert_error)?;
@@ -543,10 +564,10 @@ pub fn verify_archive_sync(
543
564
  /// The `progress` callback is called once per entry with
544
565
  /// `(path, total, current, bytesWritten)` where:
545
566
  /// - `path` — entry path inside the archive
546
- /// - `total` — total number of entries as `bigint` (0 for TAR-family formats
567
+ /// - `total` — total number of entries as `number` (0 for TAR-family formats
547
568
  /// because the entry count is unknown until the stream is fully read)
548
- /// - `current` — 1-based index of the current entry as `bigint`
549
- /// - `bytesWritten` — cumulative bytes written to disk so far as `bigint`
569
+ /// - `current` — 1-based index of the current entry as `number`
570
+ /// - `bytesWritten` — cumulative bytes written to disk so far as `number`
550
571
  /// (always 0 during extraction because the core library does not emit
551
572
  /// byte-level progress events for extraction; only entry-level events fire)
552
573
  ///
@@ -558,8 +579,9 @@ pub fn verify_archive_sync(
558
579
  /// * `archive_path` - Path to the archive file
559
580
  /// * `output_dir` - Directory where files will be extracted
560
581
  /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
561
- /// * `progress` - Optional progress callback `(path: string, total: bigint,
562
- /// current: bigint, bytesWritten: bigint) => void`
582
+ /// * `options` - Optional `ExtractionOptions` (uses defaults if omitted)
583
+ /// * `progress` - Optional progress callback `(path: string, total: number,
584
+ /// current: number, bytesWritten: number) => void`
563
585
  ///
564
586
  /// # Returns
565
587
  ///
@@ -578,6 +600,7 @@ pub fn verify_archive_sync(
578
600
  /// 'archive.tar.gz',
579
601
  /// '/tmp/output',
580
602
  /// null,
603
+ /// null,
581
604
  /// (path, total, current, bytesWritten) => {
582
605
  /// console.log(`${current}/${total}: ${path}`);
583
606
  /// },
@@ -590,6 +613,7 @@ pub async fn extract_archive_with_progress(
590
613
  archive_path: String,
591
614
  output_dir: String,
592
615
  config: Option<&SecurityConfig>,
616
+ options: Option<&ExtractionOptions>,
593
617
  progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
594
618
  ) -> Result<ExtractionReport> {
595
619
  validate_path(&archive_path)?;
@@ -597,11 +621,19 @@ pub async fn extract_archive_with_progress(
597
621
 
598
622
  let config_owned: exarch_core::SecurityConfig =
599
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();
600
626
 
601
627
  let report = tokio::task::spawn_blocking(move || {
602
628
  std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
603
- run_extract_with_optional_progress(&archive_path, &output_dir, &config_owned, progress)
604
- .map_err(convert_error)
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)
605
637
  }))
606
638
  .map_err(|_| Error::from_reason("Internal panic during archive extraction with progress"))
607
639
  .flatten()
@@ -613,25 +645,33 @@ pub async fn extract_archive_with_progress(
613
645
  Ok(ExtractionReport::from(report))
614
646
  }
615
647
 
616
- /// Runs `extract_archive_with_progress` routing to the JS callback when present
617
- /// or to [`exarch_core::NoopProgress`] when absent.
648
+ /// Runs `extract_archive_with_options_and_progress` routing to the JS callback
649
+ /// when present or to [`exarch_core::NoopProgress`] when absent.
618
650
  fn run_extract_with_optional_progress(
619
651
  archive_path: &str,
620
652
  output_dir: &str,
621
653
  config: &exarch_core::SecurityConfig,
654
+ options: &exarch_core::ExtractionOptions,
622
655
  progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
623
656
  ) -> exarch_core::Result<exarch_core::ExtractionReport> {
624
657
  progress.map_or_else(
625
658
  || {
626
659
  let mut noop = exarch_core::NoopProgress;
627
- exarch_core::extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
660
+ exarch_core::extract_archive_with_options_and_progress(
661
+ archive_path,
662
+ output_dir,
663
+ config,
664
+ options,
665
+ &mut noop,
666
+ )
628
667
  },
629
668
  |tsfn| {
630
669
  let mut callback = NodeProgressAdapter::new(tsfn);
631
- exarch_core::extract_archive_with_progress(
670
+ exarch_core::extract_archive_with_options_and_progress(
632
671
  archive_path,
633
672
  output_dir,
634
673
  config,
674
+ options,
635
675
  &mut callback,
636
676
  )
637
677
  },
@@ -700,6 +740,7 @@ mod tests {
700
740
  "/tmp/test\0malicious.tar".to_string(),
701
741
  "/tmp/output".to_string(),
702
742
  None,
743
+ None,
703
744
  )
704
745
  .await;
705
746
 
@@ -716,6 +757,7 @@ mod tests {
716
757
  "/tmp/test.tar".to_string(),
717
758
  "/tmp/output\0malicious".to_string(),
718
759
  None,
760
+ None,
719
761
  )
720
762
  .await;
721
763
 
@@ -729,7 +771,7 @@ mod tests {
729
771
  #[tokio::test]
730
772
  async fn test_extract_archive_rejects_excessively_long_archive_path() {
731
773
  let long_path = "x".repeat(5000);
732
- 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;
733
775
 
734
776
  assert!(result.is_err(), "should reject excessively long paths");
735
777
  assert!(
@@ -741,7 +783,7 @@ mod tests {
741
783
  #[tokio::test]
742
784
  async fn test_extract_archive_rejects_excessively_long_output_dir() {
743
785
  let long_path = "x".repeat(5000);
744
- 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;
745
787
 
746
788
  assert!(result.is_err(), "should reject excessively long paths");
747
789
  assert!(
@@ -754,7 +796,7 @@ mod tests {
754
796
  async fn test_extract_archive_accepts_empty_paths() {
755
797
  // Empty paths should be accepted at boundary validation
756
798
  // Core library will handle actual path validation
757
- let result = extract_archive("".to_string(), "".to_string(), None).await;
799
+ let result = extract_archive("".to_string(), "".to_string(), None, None).await;
758
800
 
759
801
  // If it fails, ensure it's not a boundary path validation error
760
802
  // (empty paths pass boundary validation; core handles semantic validation)
@@ -776,6 +818,7 @@ mod tests {
776
818
  "/tmp/test\0malicious.tar".to_string(),
777
819
  "/tmp/output".to_string(),
778
820
  None,
821
+ None,
779
822
  );
780
823
 
781
824
  assert!(result.is_err(), "should reject null bytes in archive path");
@@ -791,6 +834,7 @@ mod tests {
791
834
  "/tmp/test.tar".to_string(),
792
835
  "/tmp/output\0malicious".to_string(),
793
836
  None,
837
+ None,
794
838
  );
795
839
 
796
840
  assert!(result.is_err(), "should reject null bytes in output path");
@@ -803,7 +847,7 @@ mod tests {
803
847
  #[test]
804
848
  fn test_extract_archive_sync_rejects_excessively_long_archive_path() {
805
849
  let long_path = "x".repeat(5000);
806
- 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);
807
851
 
808
852
  assert!(result.is_err(), "should reject excessively long paths");
809
853
  assert!(
@@ -815,7 +859,7 @@ mod tests {
815
859
  #[test]
816
860
  fn test_extract_archive_sync_rejects_excessively_long_output_dir() {
817
861
  let long_path = "x".repeat(5000);
818
- 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);
819
863
 
820
864
  assert!(result.is_err(), "should reject excessively long paths");
821
865
  assert!(
@@ -833,6 +877,7 @@ mod tests {
833
877
  "/tmp/valid_test_path.tar".to_string(),
834
878
  "/tmp/valid_output_path".to_string(),
835
879
  None,
880
+ None,
836
881
  );
837
882
 
838
883
  // If it fails, ensure it's not a path validation error
@@ -854,6 +899,7 @@ mod tests {
854
899
  "relative_test.tar".to_string(),
855
900
  "relative_output".to_string(),
856
901
  None,
902
+ None,
857
903
  );
858
904
 
859
905
  // If it fails, ensure it's not a path validation error
@@ -878,6 +924,7 @@ mod tests {
878
924
  "custom_test.tar".to_string(),
879
925
  "custom_output".to_string(),
880
926
  Some(&config),
927
+ None,
881
928
  );
882
929
 
883
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
+ });