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.
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exarch-rs",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Memory-safe archive extraction library with built-in security validation",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
package/src/config.rs CHANGED
@@ -1,4 +1,4 @@
1
- //! Node.js bindings for `SecurityConfig`.
1
+ //! Node.js bindings for `SecurityConfig` and `ExtractionOptions`.
2
2
 
3
3
  use exarch_core::SecurityConfig as CoreConfig;
4
4
  use napi::bindgen_prelude::Error;
@@ -30,7 +30,7 @@ const MAX_COMPONENT_LENGTH: usize = 255;
30
30
  /// | `allow_world_writable` | false |
31
31
  /// | `preserve_permissions` | false |
32
32
  /// | `allowed_extensions` | empty (all allowed) |
33
- /// | `banned_path_components` | `.git`, `.ssh` |
33
+ /// | `banned_path_components` | `.git`, `.ssh`, `.gnupg`, `.aws`, `.kube`, `.docker`, `.env` |
34
34
  #[napi]
35
35
  #[derive(Debug, Clone)]
36
36
  pub struct SecurityConfig {
@@ -171,6 +171,29 @@ impl SecurityConfig {
171
171
  self
172
172
  }
173
173
 
174
+ /// Sets the maximum memory that may be used to decompress a solid 7z block.
175
+ ///
176
+ /// Solid archives decompress entire blocks into memory before individual
177
+ /// entries can be read. This limit caps that allocation to prevent
178
+ /// memory exhaustion from crafted archives. Default: 512 MB.
179
+ ///
180
+ /// # Errors
181
+ ///
182
+ /// Returns error if size is negative or zero.
183
+ #[napi(js_name = "setMaxSolidBlockMemory")]
184
+ pub fn set_max_solid_block_memory(&mut self, size: i64) -> Result<&Self> {
185
+ if size <= 0 {
186
+ return Err(Error::from_reason(
187
+ "max solid block memory must be a positive number",
188
+ ));
189
+ }
190
+ #[allow(clippy::cast_sign_loss)]
191
+ {
192
+ self.inner.max_solid_block_memory = size as u64;
193
+ }
194
+ Ok(self)
195
+ }
196
+
174
197
  /// Sets whether to preserve permissions from archive.
175
198
  #[napi(js_name = "setPreservePermissions")]
176
199
  pub fn set_preserve_permissions(&mut self, preserve: Option<bool>) -> &Self {
@@ -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::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: ");
@@ -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 test_unsupported_format_conversion() {
337
- let err = CoreError::UnsupportedFormat;
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("UNSUPPORTED_FORMAT"));
341
- assert!(err_str.contains("unsupported archive format"));
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 embed `filesExtracted`
365
- /// and `bytesWritten` in the error message for `PartialExtraction`.
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 test_partial_extraction_node_message_format() {
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.contains("PARTIAL_EXTRACTION"),
388
- "message must contain PARTIAL_EXTRACTION, got: {msg}"
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
  }