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/README.md +30 -2
- package/index.d.ts +789 -0
- package/native/exarch-rs.darwin-arm64.node +0 -0
- package/native/exarch-rs.darwin-x64.node +0 -0
- package/native/exarch-rs.linux-arm64-gnu.node +0 -0
- package/native/exarch-rs.linux-x64-gnu.node +0 -0
- package/native/exarch-rs.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/src/config.rs +192 -2
- package/src/error.rs +104 -15
- package/src/lib.rs +235 -27
- package/tests/extraction-options.test.js +133 -0
- package/tests/security-config.test.js +13 -0
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().
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
-
.
|
|
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().
|
|
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::
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
272
|
-
.
|
|
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
|
-
|
|
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
|
|
371
|
-
.
|
|
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
|
-
|
|
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
|
|
468
|
-
.
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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);
|