exarch-rs 0.3.1 → 0.4.1

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 CHANGED
@@ -161,19 +161,51 @@ interface ExtractionReport {
161
161
  }
162
162
  ```
163
163
 
164
+ ### `extractArchiveWithProgress(archivePath, outputDir, config?, progress?)`
165
+
166
+ Async extraction with an optional progress callback.
167
+
168
+ ```typescript
169
+ import { extractArchiveWithProgress } from 'exarch-rs';
170
+
171
+ const result = await extractArchiveWithProgress(
172
+ 'archive.tar.gz',
173
+ '/output/path',
174
+ undefined, // SecurityConfig or undefined
175
+ (path, total, current, bytesWritten) => {
176
+ console.log(`[${current}/${total}] ${path} (${bytesWritten} bytes)`);
177
+ }
178
+ );
179
+ ```
180
+
181
+ **Parameters:**
182
+
183
+ | Name | Type | Description |
184
+ |------|------|-------------|
185
+ | `archivePath` | `string` | Path to the archive file |
186
+ | `outputDir` | `string` | Directory where files will be extracted |
187
+ | `config` | `SecurityConfig \| undefined` | Optional security configuration |
188
+ | `progress` | `(path: string, total: bigint, current: bigint, bytesWritten: bigint) => void \| undefined` | Optional progress callback |
189
+
190
+ **Returns:** `Promise<ExtractionReport>`
191
+
164
192
  ### `SecurityConfig`
165
193
 
166
194
  Builder-style security configuration.
167
195
 
168
196
  ```typescript
169
197
  const config = new SecurityConfig()
170
- .maxFileSize(bytes) // Max size per file
171
- .maxTotalSize(bytes) // Max total extraction size
172
- .maxFileCount(count) // Max number of files
173
- .maxCompressionRatio(n) // Max compression ratio (zip bomb detection)
174
- .setAllowSolidArchives(true); // Allow solid 7z archives (default: false)
198
+ .maxFileSize(bytes) // Max size per file
199
+ .maxTotalSize(bytes) // Max total extraction size
200
+ .maxFileCount(count) // Max number of files
201
+ .maxCompressionRatio(n) // Max compression ratio (zip bomb detection)
202
+ .allowedExtensions([".txt", ".md"]) // Restrict to a set of extensions
203
+ .bannedPathComponents(["__MACOSX"]) // Skip these path components
204
+ .setAllowSolidArchives(true); // Allow solid 7z archives (default: false)
175
205
  ```
176
206
 
207
+ **Getters:** `allowSymlinks`, `allowHardlinks`, `allowAbsolutePaths`, `allowWorldWritable`, `allowSolidArchives` — each returns the corresponding boolean policy value.
208
+
177
209
  ## Security Features
178
210
 
179
211
  The library provides built-in protection against:
@@ -203,6 +235,8 @@ The library provides built-in protection against:
203
235
 
204
236
  **Note:** 7z creation is not yet supported. Solid and encrypted 7z archives are rejected for security reasons. Unix symlinks inside 7z archives are reported as regular files (sevenz-rust2 API limitation).
205
237
 
238
+ **Note:** Since v0.4.0, partial extraction failures return the `ExtractionReport` accumulated up to the failure point without the inner error text being duplicated, and the report is now correctly delivered across the FFI boundary instead of being dropped early.
239
+
206
240
  ## Comparison with tar-fs
207
241
 
208
242
  ```javascript
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exarch-rs",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
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
@@ -313,6 +313,12 @@ impl SecurityConfig {
313
313
  self.inner.allowed.world_writable
314
314
  }
315
315
 
316
+ /// Whether solid 7z archives are allowed.
317
+ #[napi(getter)]
318
+ pub fn get_allow_solid_archives(&self) -> bool {
319
+ self.inner.allow_solid_archives
320
+ }
321
+
316
322
  /// List of allowed file extensions.
317
323
  ///
318
324
  /// Note: This getter clones the underlying data. For performance-critical
@@ -829,6 +835,22 @@ mod tests {
829
835
  );
830
836
  }
831
837
 
838
+ #[test]
839
+ fn test_allow_solid_archives_default_and_round_trip() {
840
+ let config = SecurityConfig::new();
841
+ assert!(
842
+ !config.get_allow_solid_archives(),
843
+ "allow_solid_archives should default to false"
844
+ );
845
+
846
+ let mut config = SecurityConfig::new();
847
+ config.set_allow_solid_archives(Some(true));
848
+ assert!(
849
+ config.get_allow_solid_archives(),
850
+ "allow_solid_archives getter should reflect setter value"
851
+ );
852
+ }
853
+
832
854
  #[test]
833
855
  fn test_allowed_extensions_getter_after_add() {
834
856
  let mut config = SecurityConfig::new();
package/src/error.rs CHANGED
@@ -1,6 +1,6 @@
1
1
  //! Error conversion for Node.js bindings.
2
2
 
3
- use exarch_core::ExtractionError as CoreError;
3
+ use exarch_core::ArchiveError as CoreError;
4
4
  use exarch_core::QuotaResource as CoreQuotaResource;
5
5
  use napi::bindgen_prelude::*;
6
6
  use std::path::Path;
@@ -123,10 +123,6 @@ pub fn convert_error(err: CoreError) -> Error {
123
123
  msg.push_str(&reason);
124
124
  Error::new(Status::GenericFailure, msg)
125
125
  }
126
- CoreError::UnsupportedFormat => Error::new(
127
- Status::GenericFailure,
128
- "UNSUPPORTED_FORMAT: unsupported archive format",
129
- ),
130
126
  CoreError::InvalidArchive(archive_msg) => {
131
127
  let mut msg = String::with_capacity(30 + archive_msg.len());
132
128
  msg.push_str("INVALID_ARCHIVE: invalid archive: ");
@@ -182,7 +178,21 @@ pub fn convert_error(err: CoreError) -> Error {
182
178
  msg.push_str(&reason);
183
179
  Error::new(Status::GenericFailure, msg)
184
180
  }
185
- CoreError::PartialExtraction { source, .. } => convert_error(*source),
181
+ CoreError::PartialExtraction { source, report } => {
182
+ // Preserve the specific error code (SYMLINK_ESCAPE, QUOTA_EXCEEDED, etc.) from
183
+ // the inner source so JavaScript callers can distinguish the error type. The
184
+ // partial-extraction report fields from #210 are appended for caller
185
+ // inspection.
186
+ let inner = convert_error(*source);
187
+ let mut msg = String::with_capacity(inner.reason.len() + 64);
188
+ msg.push_str(&inner.reason);
189
+ let _ = write!(
190
+ &mut msg,
191
+ " | filesExtracted={}, bytesWritten={}",
192
+ report.files_extracted, report.bytes_written
193
+ );
194
+ Error::new(Status::GenericFailure, msg)
195
+ }
186
196
  }
187
197
  }
188
198
 
@@ -322,12 +332,14 @@ mod tests {
322
332
  }
323
333
 
324
334
  #[test]
325
- fn test_unsupported_format_conversion() {
326
- let err = CoreError::UnsupportedFormat;
335
+ fn test_unknown_format_conversion() {
336
+ let err = CoreError::UnknownFormat {
337
+ path: std::path::PathBuf::from("archive.rar"),
338
+ };
327
339
  let napi_err = convert_error(err);
328
340
  let err_str = napi_err.to_string();
329
- assert!(err_str.contains("UNSUPPORTED_FORMAT"));
330
- assert!(err_str.contains("unsupported archive format"));
341
+ assert!(err_str.contains("UNKNOWN_FORMAT"));
342
+ assert!(err_str.contains("cannot determine archive format"));
331
343
  }
332
344
 
333
345
  #[test]
@@ -349,4 +361,128 @@ mod tests {
349
361
  assert!(err_str.contains("IO_ERROR"));
350
362
  assert!(err_str.contains("file not found"));
351
363
  }
364
+
365
+ /// Regression test for #251 + #210: `convert_error` must preserve the
366
+ /// specific error code from the inner source (e.g. `QUOTA_EXCEEDED`,
367
+ /// not `PARTIAL_EXTRACTION`), while still appending `filesExtracted`
368
+ /// and `bytesWritten` for caller inspection.
369
+ #[test]
370
+ fn test_partial_extraction_preserves_specific_code_with_report() {
371
+ use exarch_core::ExtractionReport;
372
+ use exarch_core::QuotaResource;
373
+
374
+ let report = ExtractionReport {
375
+ files_extracted: 3,
376
+ bytes_written: 1024,
377
+ ..ExtractionReport::default()
378
+ };
379
+ let source = CoreError::QuotaExceeded {
380
+ resource: QuotaResource::FileCount { current: 4, max: 3 },
381
+ };
382
+ let err = CoreError::PartialExtraction {
383
+ source: Box::new(source),
384
+ report,
385
+ };
386
+
387
+ let napi_err = convert_error(err);
388
+ let msg = napi_err.reason.clone();
389
+ // Must carry the specific error code, not the generic PARTIAL_EXTRACTION
390
+ // prefix.
391
+ assert!(
392
+ msg.starts_with("QUOTA_EXCEEDED"),
393
+ "message must start with QUOTA_EXCEEDED, got: {msg}"
394
+ );
395
+ assert!(
396
+ msg.contains("filesExtracted=3"),
397
+ "message must contain filesExtracted=3, got: {msg}"
398
+ );
399
+ assert!(
400
+ msg.contains("bytesWritten=1024"),
401
+ "message must contain bytesWritten=1024, got: {msg}"
402
+ );
403
+ }
404
+
405
+ // Regression tests for #251: security error variants must also preserve
406
+ // their specific error code and carry the #210 report fields.
407
+ //
408
+ // Helper that asserts the produced error reason starts with the given code
409
+ // and contains the expected report fields.
410
+ fn assert_partial_node_report(napi_err: &Error, expected_code: &str, files: usize, bytes: u64) {
411
+ let msg = &napi_err.reason;
412
+ assert!(
413
+ msg.starts_with(expected_code),
414
+ "message must start with {expected_code}, got: {msg}"
415
+ );
416
+ assert!(
417
+ msg.contains(&format!("filesExtracted={files}")),
418
+ "message must contain filesExtracted={files}, got: {msg}"
419
+ );
420
+ assert!(
421
+ msg.contains(&format!("bytesWritten={bytes}")),
422
+ "message must contain bytesWritten={bytes}, got: {msg}"
423
+ );
424
+ }
425
+
426
+ #[test]
427
+ fn test_partial_extraction_symlink_escape_preserves_code_and_report() {
428
+ use exarch_core::ExtractionReport;
429
+
430
+ let report = ExtractionReport {
431
+ files_extracted: 2,
432
+ bytes_written: 512,
433
+ ..ExtractionReport::default()
434
+ };
435
+ let source = CoreError::SymlinkEscape {
436
+ path: PathBuf::from("/etc/passwd"),
437
+ };
438
+ let err = CoreError::PartialExtraction {
439
+ source: Box::new(source),
440
+ report,
441
+ };
442
+
443
+ let napi_err = convert_error(err);
444
+ assert_partial_node_report(&napi_err, "SYMLINK_ESCAPE", 2, 512);
445
+ }
446
+
447
+ #[test]
448
+ fn test_partial_extraction_hardlink_escape_preserves_code_and_report() {
449
+ use exarch_core::ExtractionReport;
450
+
451
+ let report = ExtractionReport {
452
+ files_extracted: 2,
453
+ bytes_written: 512,
454
+ ..ExtractionReport::default()
455
+ };
456
+ let source = CoreError::HardlinkEscape {
457
+ path: PathBuf::from("/etc/shadow"),
458
+ };
459
+ let err = CoreError::PartialExtraction {
460
+ source: Box::new(source),
461
+ report,
462
+ };
463
+
464
+ let napi_err = convert_error(err);
465
+ assert_partial_node_report(&napi_err, "HARDLINK_ESCAPE", 2, 512);
466
+ }
467
+
468
+ #[test]
469
+ fn test_partial_extraction_security_violation_preserves_code_and_report() {
470
+ use exarch_core::ExtractionReport;
471
+
472
+ let report = ExtractionReport {
473
+ files_extracted: 2,
474
+ bytes_written: 512,
475
+ ..ExtractionReport::default()
476
+ };
477
+ let source = CoreError::SecurityViolation {
478
+ reason: "test policy violation".to_string(),
479
+ };
480
+ let err = CoreError::PartialExtraction {
481
+ source: Box::new(source),
482
+ report,
483
+ };
484
+
485
+ let napi_err = convert_error(err);
486
+ assert_partial_node_report(&napi_err, "SECURITY_VIOLATION", 2, 512);
487
+ }
352
488
  }
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;
@@ -150,11 +152,16 @@ pub async fn extract_archive(
150
152
  // For maximum security with untrusted archives, use extractArchiveSync()
151
153
  // or ensure exclusive file access (e.g., flock) during extraction.
152
154
  let report = tokio::task::spawn_blocking(move || {
153
- exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
155
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
156
+ exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
157
+ .map_err(convert_error)
158
+ }))
159
+ .map_err(|_| Error::from_reason("Internal panic during archive extraction"))
160
+ .flatten()
154
161
  })
155
162
  .await
156
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
157
- .map_err(convert_error)?;
163
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
164
+ .flatten()?;
158
165
 
159
166
  Ok(ExtractionReport::from(report))
160
167
  }
@@ -264,12 +271,17 @@ pub async fn create_archive(
264
271
  config.map(|c| c.as_core().clone()).unwrap_or_default();
265
272
 
266
273
  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)
274
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
275
+ let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
276
+ exarch_core::create_archive(&output_path, &sources_refs, &config_owned)
277
+ .map_err(convert_error)
278
+ }))
279
+ .map_err(|_| Error::from_reason("Internal panic during archive creation"))
280
+ .flatten()
269
281
  })
270
282
  .await
271
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
272
- .map_err(convert_error)?;
283
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
284
+ .flatten()?;
273
285
 
274
286
  Ok(CreationReport::from(report))
275
287
  }
@@ -364,11 +376,15 @@ pub async fn list_archive(
364
376
  config.map(|c| c.as_core().clone()).unwrap_or_default();
365
377
 
366
378
  let manifest = tokio::task::spawn_blocking(move || {
367
- exarch_core::list_archive(&archive_path, &config_owned)
379
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
380
+ exarch_core::list_archive(&archive_path, &config_owned).map_err(convert_error)
381
+ }))
382
+ .map_err(|_| Error::from_reason("Internal panic during archive listing"))
383
+ .flatten()
368
384
  })
369
385
  .await
370
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
371
- .map_err(convert_error)?;
386
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
387
+ .flatten()?;
372
388
 
373
389
  Ok(ArchiveManifest::from(manifest))
374
390
  }
@@ -461,11 +477,15 @@ pub async fn verify_archive(
461
477
  config.map(|c| c.as_core().clone()).unwrap_or_default();
462
478
 
463
479
  let report = tokio::task::spawn_blocking(move || {
464
- exarch_core::verify_archive(&archive_path, &config_owned)
480
+ std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
481
+ exarch_core::verify_archive(&archive_path, &config_owned).map_err(convert_error)
482
+ }))
483
+ .map_err(|_| Error::from_reason("Internal panic during archive verification"))
484
+ .flatten()
465
485
  })
466
486
  .await
467
- .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
468
- .map_err(convert_error)?;
487
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
488
+ .flatten()?;
469
489
 
470
490
  Ok(VerificationReport::from(report))
471
491
  }
@@ -517,10 +537,151 @@ pub fn verify_archive_sync(
517
537
  Ok(VerificationReport::from(report))
518
538
  }
519
539
 
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.
540
+ /// Extract an archive to the specified directory with a progress callback
541
+ /// (async).
542
+ ///
543
+ /// The `progress` callback is called once per entry with
544
+ /// `(path, total, current, bytesWritten)` where:
545
+ /// - `path` — entry path inside the archive
546
+ /// - `total` — total number of entries as `bigint` (0 for TAR-family formats
547
+ /// 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`
550
+ /// (always 0 during extraction because the core library does not emit
551
+ /// byte-level progress events for extraction; only entry-level events fire)
552
+ ///
553
+ /// Extraction runs on the tokio blocking thread pool. The progress callback is
554
+ /// dispatched back to the JavaScript thread via a threadsafe function.
555
+ ///
556
+ /// # Arguments
557
+ ///
558
+ /// * `archive_path` - Path to the archive file
559
+ /// * `output_dir` - Directory where files will be extracted
560
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
561
+ /// * `progress` - Optional progress callback `(path: string, total: bigint,
562
+ /// current: bigint, bytesWritten: bigint) => void`
563
+ ///
564
+ /// # Returns
565
+ ///
566
+ /// Promise resolving to `ExtractionReport` with extraction statistics
567
+ ///
568
+ /// # Errors
569
+ ///
570
+ /// Returns error for security violations or I/O errors. Error messages are
571
+ /// prefixed with error codes for discrimination in JavaScript. See
572
+ /// `extractArchive` for the full list of error codes.
573
+ ///
574
+ /// # Examples
575
+ ///
576
+ /// ```javascript
577
+ /// const report = await extractArchiveWithProgress(
578
+ /// 'archive.tar.gz',
579
+ /// '/tmp/output',
580
+ /// null,
581
+ /// (path, total, current, bytesWritten) => {
582
+ /// console.log(`${current}/${total}: ${path}`);
583
+ /// },
584
+ /// );
585
+ /// console.log(`Extracted ${report.filesExtracted} files`);
586
+ /// ```
587
+ #[napi]
588
+ #[allow(clippy::needless_pass_by_value, clippy::trailing_empty_array)]
589
+ pub async fn extract_archive_with_progress(
590
+ archive_path: String,
591
+ output_dir: String,
592
+ config: Option<&SecurityConfig>,
593
+ progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
594
+ ) -> Result<ExtractionReport> {
595
+ validate_path(&archive_path)?;
596
+ validate_path(&output_dir)?;
597
+
598
+ let config_owned: exarch_core::SecurityConfig =
599
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
600
+
601
+ let report = tokio::task::spawn_blocking(move || {
602
+ 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)
605
+ }))
606
+ .map_err(|_| Error::from_reason("Internal panic during archive extraction with progress"))
607
+ .flatten()
608
+ })
609
+ .await
610
+ .map_err(|e| Error::from_reason(format!("task join error: {e}")))
611
+ .flatten()?;
612
+
613
+ Ok(ExtractionReport::from(report))
614
+ }
615
+
616
+ /// Runs `extract_archive_with_progress` routing to the JS callback when present
617
+ /// or to [`exarch_core::NoopProgress`] when absent.
618
+ fn run_extract_with_optional_progress(
619
+ archive_path: &str,
620
+ output_dir: &str,
621
+ config: &exarch_core::SecurityConfig,
622
+ progress: Option<ThreadsafeFunction<(String, i64, i64, i64)>>,
623
+ ) -> exarch_core::Result<exarch_core::ExtractionReport> {
624
+ progress.map_or_else(
625
+ || {
626
+ let mut noop = exarch_core::NoopProgress;
627
+ exarch_core::extract_archive_with_progress(archive_path, output_dir, config, &mut noop)
628
+ },
629
+ |tsfn| {
630
+ let mut callback = NodeProgressAdapter::new(tsfn);
631
+ exarch_core::extract_archive_with_progress(
632
+ archive_path,
633
+ output_dir,
634
+ config,
635
+ &mut callback,
636
+ )
637
+ },
638
+ )
639
+ }
640
+
641
+ /// Adapter that calls a JavaScript progress callback from a Rust worker thread.
642
+ ///
643
+ /// The JavaScript callback receives `(path: string, total: number, current:
644
+ /// number, bytesWritten: number)` where `bytesWritten` is the number of bytes
645
+ /// written **for the current entry so far** (starts at 0 when the entry begins,
646
+ /// grows as chunks are flushed to disk; always 0 during extraction because the
647
+ /// core library does not emit byte-level progress events for extraction).
648
+ struct NodeProgressAdapter {
649
+ tsfn: ThreadsafeFunction<(String, i64, i64, i64)>,
650
+ current_entry_bytes: i64,
651
+ total: usize,
652
+ }
653
+
654
+ impl NodeProgressAdapter {
655
+ fn new(tsfn: ThreadsafeFunction<(String, i64, i64, i64)>) -> Self {
656
+ Self {
657
+ tsfn,
658
+ current_entry_bytes: 0,
659
+ total: 0,
660
+ }
661
+ }
662
+ }
663
+
664
+ impl exarch_core::ProgressCallback for NodeProgressAdapter {
665
+ fn on_entry_start(&mut self, path: &std::path::Path, total: usize, current: usize) {
666
+ self.current_entry_bytes = 0;
667
+ self.total = total;
668
+ let path_str = path.to_string_lossy().into_owned();
669
+ let total_i64 = i64::try_from(total).unwrap_or(i64::MAX);
670
+ let current_i64 = i64::try_from(current).unwrap_or(i64::MAX);
671
+ self.tsfn.call(
672
+ Ok((path_str, total_i64, current_i64, self.current_entry_bytes)),
673
+ ThreadsafeFunctionCallMode::NonBlocking,
674
+ );
675
+ }
676
+
677
+ fn on_bytes_written(&mut self, bytes: u64) {
678
+ self.current_entry_bytes = self.current_entry_bytes.saturating_add(bytes.cast_signed());
679
+ }
680
+
681
+ fn on_entry_complete(&mut self, _path: &std::path::Path) {}
682
+
683
+ fn on_complete(&mut self) {}
684
+ }
524
685
 
525
686
  #[cfg(test)]
526
687
  #[allow(
@@ -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);