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
|
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 {
|
|
@@ -313,6 +336,19 @@ impl SecurityConfig {
|
|
|
313
336
|
self.inner.allowed.world_writable
|
|
314
337
|
}
|
|
315
338
|
|
|
339
|
+
/// Whether solid 7z archives are allowed.
|
|
340
|
+
#[napi(getter)]
|
|
341
|
+
pub fn get_allow_solid_archives(&self) -> bool {
|
|
342
|
+
self.inner.allow_solid_archives
|
|
343
|
+
}
|
|
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
|
+
|
|
316
352
|
/// List of allowed file extensions.
|
|
317
353
|
///
|
|
318
354
|
/// Note: This getter clones the underlying data. For performance-critical
|
|
@@ -551,6 +587,96 @@ impl CreationConfig {
|
|
|
551
587
|
}
|
|
552
588
|
}
|
|
553
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
|
+
|
|
554
680
|
#[cfg(test)]
|
|
555
681
|
#[allow(
|
|
556
682
|
clippy::unwrap_used,
|
|
@@ -829,6 +955,70 @@ mod tests {
|
|
|
829
955
|
);
|
|
830
956
|
}
|
|
831
957
|
|
|
958
|
+
#[test]
|
|
959
|
+
fn test_allow_solid_archives_default_and_round_trip() {
|
|
960
|
+
let config = SecurityConfig::new();
|
|
961
|
+
assert!(
|
|
962
|
+
!config.get_allow_solid_archives(),
|
|
963
|
+
"allow_solid_archives should default to false"
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
let mut config = SecurityConfig::new();
|
|
967
|
+
config.set_allow_solid_archives(Some(true));
|
|
968
|
+
assert!(
|
|
969
|
+
config.get_allow_solid_archives(),
|
|
970
|
+
"allow_solid_archives getter should reflect setter value"
|
|
971
|
+
);
|
|
972
|
+
}
|
|
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
|
+
|
|
832
1022
|
#[test]
|
|
833
1023
|
fn test_allowed_extensions_getter_after_add() {
|
|
834
1024
|
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::
|
|
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: ");
|
|
@@ -183,9 +179,12 @@ pub fn convert_error(err: CoreError) -> Error {
|
|
|
183
179
|
Error::new(Status::GenericFailure, msg)
|
|
184
180
|
}
|
|
185
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
186
|
let inner = convert_error(*source);
|
|
187
187
|
let mut msg = String::with_capacity(inner.reason.len() + 64);
|
|
188
|
-
msg.push_str("PARTIAL_EXTRACTION: ");
|
|
189
188
|
msg.push_str(&inner.reason);
|
|
190
189
|
let _ = write!(
|
|
191
190
|
&mut msg,
|
|
@@ -333,12 +332,14 @@ mod tests {
|
|
|
333
332
|
}
|
|
334
333
|
|
|
335
334
|
#[test]
|
|
336
|
-
fn
|
|
337
|
-
let err = CoreError::
|
|
335
|
+
fn test_unknown_format_conversion() {
|
|
336
|
+
let err = CoreError::UnknownFormat {
|
|
337
|
+
path: std::path::PathBuf::from("archive.rar"),
|
|
338
|
+
};
|
|
338
339
|
let napi_err = convert_error(err);
|
|
339
340
|
let err_str = napi_err.to_string();
|
|
340
|
-
assert!(err_str.contains("
|
|
341
|
-
assert!(err_str.contains("
|
|
341
|
+
assert!(err_str.contains("UNKNOWN_FORMAT"));
|
|
342
|
+
assert!(err_str.contains("cannot determine archive format"));
|
|
342
343
|
}
|
|
343
344
|
|
|
344
345
|
#[test]
|
|
@@ -361,10 +362,12 @@ mod tests {
|
|
|
361
362
|
assert!(err_str.contains("file not found"));
|
|
362
363
|
}
|
|
363
364
|
|
|
364
|
-
/// Regression test for #210: `convert_error` must
|
|
365
|
-
///
|
|
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.
|
|
366
369
|
#[test]
|
|
367
|
-
fn
|
|
370
|
+
fn test_partial_extraction_preserves_specific_code_with_report() {
|
|
368
371
|
use exarch_core::ExtractionReport;
|
|
369
372
|
use exarch_core::QuotaResource;
|
|
370
373
|
|
|
@@ -383,9 +386,11 @@ mod tests {
|
|
|
383
386
|
|
|
384
387
|
let napi_err = convert_error(err);
|
|
385
388
|
let msg = napi_err.reason.clone();
|
|
389
|
+
// Must carry the specific error code, not the generic PARTIAL_EXTRACTION
|
|
390
|
+
// prefix.
|
|
386
391
|
assert!(
|
|
387
|
-
msg.
|
|
388
|
-
"message must
|
|
392
|
+
msg.starts_with("QUOTA_EXCEEDED"),
|
|
393
|
+
"message must start with QUOTA_EXCEEDED, got: {msg}"
|
|
389
394
|
);
|
|
390
395
|
assert!(
|
|
391
396
|
msg.contains("filesExtracted=3"),
|
|
@@ -396,4 +401,88 @@ mod tests {
|
|
|
396
401
|
"message must contain bytesWritten=1024, got: {msg}"
|
|
397
402
|
);
|
|
398
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
|
+
}
|
|
399
488
|
}
|