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.
- package/README.md +0 -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 +170 -2
- package/src/lib.rs +70 -23
- package/tests/extraction-options.test.js +133 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
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().
|
|
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
|
|
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::
|
|
157
|
-
|
|
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().
|
|
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::
|
|
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 `
|
|
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 `
|
|
549
|
-
/// - `bytesWritten` — cumulative bytes written to disk so far as `
|
|
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
|
-
/// * `
|
|
562
|
-
///
|
|
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(
|
|
604
|
-
|
|
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 `
|
|
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::
|
|
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::
|
|
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
|
+
});
|