exarch-rs 0.1.0 → 0.1.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/src/lib.rs CHANGED
@@ -40,6 +40,9 @@
40
40
  //!
41
41
  //! MIT OR Apache-2.0
42
42
 
43
+ // Allow trailing_empty_array from napi macro - this is expected behavior
44
+ #![allow(clippy::trailing_empty_array)]
45
+
43
46
  use napi::bindgen_prelude::*;
44
47
  use napi_derive::napi;
45
48
 
@@ -48,9 +51,13 @@ mod error;
48
51
  mod report;
49
52
  mod utils;
50
53
 
54
+ use config::CreationConfig;
51
55
  use config::SecurityConfig;
52
56
  use error::convert_error;
57
+ use report::ArchiveManifest;
58
+ use report::CreationReport;
53
59
  use report::ExtractionReport;
60
+ use report::VerificationReport;
54
61
  use utils::validate_path;
55
62
 
56
63
  /// Extract an archive to the specified directory (async).
@@ -127,12 +134,9 @@ pub async fn extract_archive(
127
134
  validate_path(&archive_path)?;
128
135
  validate_path(&output_dir)?;
129
136
 
130
- // Get config reference or use default
131
- let default_config = exarch_core::SecurityConfig::default();
132
- let config_ref = config.map_or(&default_config, |c| c.as_core());
133
-
134
- // Use Arc to share config across thread boundary without cloning
135
- let config_arc = std::sync::Arc::new(config_ref.clone());
137
+ // Get owned config - clone only when config is Some, use default otherwise
138
+ let config_owned: exarch_core::SecurityConfig =
139
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
136
140
 
137
141
  // Run extraction on tokio thread pool
138
142
  //
@@ -146,7 +150,7 @@ pub async fn extract_archive(
146
150
  // For maximum security with untrusted archives, use extractArchiveSync()
147
151
  // or ensure exclusive file access (e.g., flock) during extraction.
148
152
  let report = tokio::task::spawn_blocking(move || {
149
- exarch_core::extract_archive(&archive_path, &output_dir, &config_arc)
153
+ exarch_core::extract_archive(&archive_path, &output_dir, &config_owned)
150
154
  })
151
155
  .await
152
156
  .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
@@ -211,6 +215,309 @@ pub fn extract_archive_sync(
211
215
  Ok(ExtractionReport::from(report))
212
216
  }
213
217
 
218
+ /// Create an archive from source files and directories (async).
219
+ ///
220
+ /// # Arguments
221
+ ///
222
+ /// * `output_path` - Path to output archive file
223
+ /// * `sources` - Array of source files/directories to include
224
+ /// * `config` - Optional `CreationConfig` (uses defaults if omitted)
225
+ ///
226
+ /// # Returns
227
+ ///
228
+ /// Promise resolving to `CreationReport` with creation statistics
229
+ ///
230
+ /// # Errors
231
+ ///
232
+ /// Returns error if path validation fails, archive creation fails, or I/O
233
+ /// errors occur.
234
+ ///
235
+ /// # Examples
236
+ ///
237
+ /// ```javascript
238
+ /// // Use defaults
239
+ /// const report = await createArchive('output.tar.gz', ['source_dir/']);
240
+ /// console.log(`Created archive with ${report.filesAdded} files`);
241
+ ///
242
+ /// // Customize configuration
243
+ /// const config = new CreationConfig().compressionLevel(9);
244
+ /// const report = await createArchive('output.tar.gz', ['src/'], config);
245
+ /// ```
246
+ #[napi]
247
+ #[allow(clippy::needless_pass_by_value)]
248
+ pub async fn create_archive(
249
+ output_path: String,
250
+ sources: Vec<String>,
251
+ config: Option<&CreationConfig>,
252
+ ) -> Result<CreationReport> {
253
+ validate_path(&output_path)?;
254
+ for source in &sources {
255
+ validate_path(source)?;
256
+ }
257
+
258
+ // Get owned config - clone only when config is Some, use default otherwise
259
+ let config_owned: exarch_core::creation::CreationConfig =
260
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
261
+
262
+ let report = tokio::task::spawn_blocking(move || {
263
+ let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
264
+ exarch_core::create_archive(&output_path, &sources_refs, &config_owned)
265
+ })
266
+ .await
267
+ .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
268
+ .map_err(convert_error)?;
269
+
270
+ Ok(CreationReport::from(report))
271
+ }
272
+
273
+ /// Create an archive from source files and directories (sync).
274
+ ///
275
+ /// Synchronous version of `createArchive`. Blocks the event loop until
276
+ /// creation completes. Prefer the async version for most use cases.
277
+ ///
278
+ /// # Arguments
279
+ ///
280
+ /// * `output_path` - Path to output archive file
281
+ /// * `sources` - Array of source files/directories to include
282
+ /// * `config` - Optional `CreationConfig` (uses defaults if omitted)
283
+ ///
284
+ /// # Returns
285
+ ///
286
+ /// `CreationReport` with creation statistics
287
+ ///
288
+ /// # Errors
289
+ ///
290
+ /// Returns error if path validation fails, archive creation fails, or I/O
291
+ /// errors occur.
292
+ ///
293
+ /// # Examples
294
+ ///
295
+ /// ```javascript
296
+ /// // Use defaults
297
+ /// const report = createArchiveSync('output.tar.gz', ['source_dir/']);
298
+ /// console.log(`Created archive with ${report.filesAdded} files`);
299
+ /// ```
300
+ #[napi]
301
+ #[allow(clippy::needless_pass_by_value)]
302
+ pub fn create_archive_sync(
303
+ output_path: String,
304
+ sources: Vec<String>,
305
+ config: Option<&CreationConfig>,
306
+ ) -> Result<CreationReport> {
307
+ validate_path(&output_path)?;
308
+ for source in &sources {
309
+ validate_path(source)?;
310
+ }
311
+
312
+ let default_config = exarch_core::creation::CreationConfig::default();
313
+ let config_ref = config.map_or(&default_config, |c| c.as_core());
314
+
315
+ let sources_refs: Vec<&str> = sources.iter().map(String::as_str).collect();
316
+
317
+ let report = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
318
+ exarch_core::create_archive(&output_path, &sources_refs, config_ref)
319
+ }))
320
+ .map_err(|_| Error::from_reason("Internal panic during archive creation"))?
321
+ .map_err(convert_error)?;
322
+
323
+ Ok(CreationReport::from(report))
324
+ }
325
+
326
+ /// List archive contents without extracting (async).
327
+ ///
328
+ /// # Arguments
329
+ ///
330
+ /// * `archive_path` - Path to archive file
331
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
332
+ ///
333
+ /// # Returns
334
+ ///
335
+ /// Promise resolving to `ArchiveManifest` with entry metadata
336
+ ///
337
+ /// # Errors
338
+ ///
339
+ /// Returns error if path validation fails, archive is invalid, or I/O errors
340
+ /// occur.
341
+ ///
342
+ /// # Examples
343
+ ///
344
+ /// ```javascript
345
+ /// const manifest = await listArchive('archive.tar.gz');
346
+ /// for (const entry of manifest.entries) {
347
+ /// console.log(`${entry.path}: ${entry.size} bytes`);
348
+ /// }
349
+ /// ```
350
+ #[napi]
351
+ #[allow(clippy::needless_pass_by_value)]
352
+ pub async fn list_archive(
353
+ archive_path: String,
354
+ config: Option<&SecurityConfig>,
355
+ ) -> Result<ArchiveManifest> {
356
+ validate_path(&archive_path)?;
357
+
358
+ // Get owned config - clone only when config is Some, use default otherwise
359
+ let config_owned: exarch_core::SecurityConfig =
360
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
361
+
362
+ let manifest = tokio::task::spawn_blocking(move || {
363
+ exarch_core::list_archive(&archive_path, &config_owned)
364
+ })
365
+ .await
366
+ .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
367
+ .map_err(convert_error)?;
368
+
369
+ Ok(ArchiveManifest::from(manifest))
370
+ }
371
+
372
+ /// List archive contents without extracting (sync).
373
+ ///
374
+ /// Synchronous version of `listArchive`. Blocks the event loop until
375
+ /// listing completes. Prefer the async version for most use cases.
376
+ ///
377
+ /// # Arguments
378
+ ///
379
+ /// * `archive_path` - Path to archive file
380
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
381
+ ///
382
+ /// # Returns
383
+ ///
384
+ /// `ArchiveManifest` with entry metadata
385
+ ///
386
+ /// # Errors
387
+ ///
388
+ /// Returns error if path validation fails, archive is invalid, or I/O errors
389
+ /// occur.
390
+ ///
391
+ /// # Examples
392
+ ///
393
+ /// ```javascript
394
+ /// const manifest = listArchiveSync('archive.tar.gz');
395
+ /// for (const entry of manifest.entries) {
396
+ /// console.log(`${entry.path}: ${entry.size} bytes`);
397
+ /// }
398
+ /// ```
399
+ #[napi]
400
+ #[allow(clippy::needless_pass_by_value)]
401
+ pub fn list_archive_sync(
402
+ archive_path: String,
403
+ config: Option<&SecurityConfig>,
404
+ ) -> Result<ArchiveManifest> {
405
+ validate_path(&archive_path)?;
406
+
407
+ let default_config = exarch_core::SecurityConfig::default();
408
+ let config_ref = config.map_or(&default_config, |c| c.as_core());
409
+
410
+ let manifest = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
411
+ exarch_core::list_archive(&archive_path, config_ref)
412
+ }))
413
+ .map_err(|_| Error::from_reason("Internal panic during archive listing"))?
414
+ .map_err(convert_error)?;
415
+
416
+ Ok(ArchiveManifest::from(manifest))
417
+ }
418
+
419
+ /// Verify archive integrity and security (async).
420
+ ///
421
+ /// # Arguments
422
+ ///
423
+ /// * `archive_path` - Path to archive file
424
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
425
+ ///
426
+ /// # Returns
427
+ ///
428
+ /// Promise resolving to `VerificationReport` with validation results
429
+ ///
430
+ /// # Errors
431
+ ///
432
+ /// Returns error if path validation fails, archive is invalid, or I/O errors
433
+ /// occur.
434
+ ///
435
+ /// # Examples
436
+ ///
437
+ /// ```javascript
438
+ /// const report = await verifyArchive('archive.tar.gz');
439
+ /// if (report.status === 'PASS') {
440
+ /// console.log('Archive is safe to extract');
441
+ /// } else {
442
+ /// for (const issue of report.issues) {
443
+ /// console.log(`[${issue.severity}] ${issue.message}`);
444
+ /// }
445
+ /// }
446
+ /// ```
447
+ #[napi]
448
+ #[allow(clippy::needless_pass_by_value)]
449
+ pub async fn verify_archive(
450
+ archive_path: String,
451
+ config: Option<&SecurityConfig>,
452
+ ) -> Result<VerificationReport> {
453
+ validate_path(&archive_path)?;
454
+
455
+ // Get owned config - clone only when config is Some, use default otherwise
456
+ let config_owned: exarch_core::SecurityConfig =
457
+ config.map(|c| c.as_core().clone()).unwrap_or_default();
458
+
459
+ let report = tokio::task::spawn_blocking(move || {
460
+ exarch_core::verify_archive(&archive_path, &config_owned)
461
+ })
462
+ .await
463
+ .map_err(|e| Error::from_reason(format!("task execution failed: {e}")))?
464
+ .map_err(convert_error)?;
465
+
466
+ Ok(VerificationReport::from(report))
467
+ }
468
+
469
+ /// Verify archive integrity and security (sync).
470
+ ///
471
+ /// Synchronous version of `verifyArchive`. Blocks the event loop until
472
+ /// verification completes. Prefer the async version for most use cases.
473
+ ///
474
+ /// # Arguments
475
+ ///
476
+ /// * `archive_path` - Path to archive file
477
+ /// * `config` - Optional `SecurityConfig` (uses secure defaults if omitted)
478
+ ///
479
+ /// # Returns
480
+ ///
481
+ /// `VerificationReport` with validation results
482
+ ///
483
+ /// # Errors
484
+ ///
485
+ /// Returns error if path validation fails, archive is invalid, or I/O errors
486
+ /// occur.
487
+ ///
488
+ /// # Examples
489
+ ///
490
+ /// ```javascript
491
+ /// const report = verifyArchiveSync('archive.tar.gz');
492
+ /// if (report.status === 'PASS') {
493
+ /// console.log('Archive is safe to extract');
494
+ /// }
495
+ /// ```
496
+ #[napi]
497
+ #[allow(clippy::needless_pass_by_value)]
498
+ pub fn verify_archive_sync(
499
+ archive_path: String,
500
+ config: Option<&SecurityConfig>,
501
+ ) -> Result<VerificationReport> {
502
+ validate_path(&archive_path)?;
503
+
504
+ let default_config = exarch_core::SecurityConfig::default();
505
+ let config_ref = config.map_or(&default_config, |c| c.as_core());
506
+
507
+ let report = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
508
+ exarch_core::verify_archive(&archive_path, config_ref)
509
+ }))
510
+ .map_err(|_| Error::from_reason("Internal panic during archive verification"))?
511
+ .map_err(convert_error)?;
512
+
513
+ Ok(VerificationReport::from(report))
514
+ }
515
+
516
+ // NOTE: Progress callback support (createArchiveWithProgress) is planned for
517
+ // a future release. The napi-rs 3.x ThreadsafeFunction API requires additional
518
+ // work to properly bridge Rust ProgressCallback trait to JavaScript callbacks.
519
+ // For now, use createArchive/createArchiveSync without progress tracking.
520
+
214
521
  #[cfg(test)]
215
522
  #[allow(
216
523
  clippy::unwrap_used,
@@ -221,12 +528,6 @@ pub fn extract_archive_sync(
221
528
  mod tests {
222
529
  use super::*;
223
530
 
224
- #[test]
225
- fn test_module_exports_functions() {
226
- // This test just ensures the module compiles and exports the expected
227
- // functions. Runtime tests would require actual archive files.
228
- }
229
-
230
531
  // CR-004: Path validation tests
231
532
  #[tokio::test]
232
533
  async fn test_extract_archive_rejects_null_byte_in_archive_path() {
@@ -405,7 +706,7 @@ mod tests {
405
706
  #[test]
406
707
  fn test_extract_archive_sync_with_custom_config() {
407
708
  let mut config = SecurityConfig::new();
408
- config.max_file_size(1_000_000).unwrap();
709
+ config.set_max_file_size(1_000_000).unwrap();
409
710
 
410
711
  // Test that valid paths pass boundary validation with custom config
411
712
  let result = extract_archive_sync(
@@ -425,4 +726,242 @@ mod tests {
425
726
  }
426
727
  // If it succeeds, path validation passed (which is what we're testing)
427
728
  }
729
+
730
+ // CR-004: create_archive path validation tests
731
+ #[tokio::test]
732
+ async fn test_create_archive_rejects_null_byte_in_output_path() {
733
+ let result = create_archive(
734
+ "/tmp/output\0malicious.tar".to_string(),
735
+ vec!["source/".to_string()],
736
+ None,
737
+ )
738
+ .await;
739
+
740
+ assert!(result.is_err(), "should reject null bytes in output path");
741
+ assert!(
742
+ result.unwrap_err().to_string().contains("null bytes"),
743
+ "error message should mention null bytes"
744
+ );
745
+ }
746
+
747
+ #[tokio::test]
748
+ async fn test_create_archive_rejects_null_byte_in_source_path() {
749
+ let result = create_archive(
750
+ "/tmp/output.tar".to_string(),
751
+ vec!["source\0malicious/".to_string()],
752
+ None,
753
+ )
754
+ .await;
755
+
756
+ assert!(result.is_err(), "should reject null bytes in source path");
757
+ assert!(
758
+ result.unwrap_err().to_string().contains("null bytes"),
759
+ "error message should mention null bytes"
760
+ );
761
+ }
762
+
763
+ #[tokio::test]
764
+ async fn test_create_archive_rejects_excessively_long_output_path() {
765
+ let long_path = "x".repeat(5000);
766
+ let result = create_archive(long_path, vec!["source/".to_string()], None).await;
767
+
768
+ assert!(result.is_err(), "should reject excessively long paths");
769
+ assert!(
770
+ result.unwrap_err().to_string().contains("maximum length"),
771
+ "error message should mention length limit"
772
+ );
773
+ }
774
+
775
+ #[tokio::test]
776
+ async fn test_create_archive_rejects_excessively_long_source_path() {
777
+ let long_path = "x".repeat(5000);
778
+ let result = create_archive("/tmp/output.tar".to_string(), vec![long_path], None).await;
779
+
780
+ assert!(result.is_err(), "should reject excessively long paths");
781
+ assert!(
782
+ result.unwrap_err().to_string().contains("maximum length"),
783
+ "error message should mention length limit"
784
+ );
785
+ }
786
+
787
+ #[tokio::test]
788
+ async fn test_create_archive_accepts_empty_sources_array() {
789
+ // Empty sources array should pass boundary validation
790
+ // Core library will handle actual validation
791
+ let result = create_archive("/tmp/output.tar".to_string(), vec![], None).await;
792
+
793
+ // If it fails, ensure it's not a boundary path validation error
794
+ if let Err(e) = result {
795
+ let err_msg = e.to_string();
796
+ assert!(
797
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
798
+ "should not fail on boundary path validation, got: {}",
799
+ err_msg
800
+ );
801
+ }
802
+ }
803
+
804
+ #[test]
805
+ fn test_create_archive_sync_rejects_null_byte_in_output_path() {
806
+ let result = create_archive_sync(
807
+ "/tmp/output\0malicious.tar".to_string(),
808
+ vec!["source/".to_string()],
809
+ None,
810
+ );
811
+
812
+ assert!(result.is_err(), "should reject null bytes in output path");
813
+ assert!(
814
+ result.unwrap_err().to_string().contains("null bytes"),
815
+ "error message should mention null bytes"
816
+ );
817
+ }
818
+
819
+ #[test]
820
+ fn test_create_archive_sync_rejects_null_byte_in_source_path() {
821
+ let result = create_archive_sync(
822
+ "/tmp/output.tar".to_string(),
823
+ vec!["source\0malicious/".to_string()],
824
+ None,
825
+ );
826
+
827
+ assert!(result.is_err(), "should reject null bytes in source path");
828
+ assert!(
829
+ result.unwrap_err().to_string().contains("null bytes"),
830
+ "error message should mention null bytes"
831
+ );
832
+ }
833
+
834
+ #[test]
835
+ fn test_create_archive_sync_rejects_excessively_long_output_path() {
836
+ let long_path = "x".repeat(5000);
837
+ let result = create_archive_sync(long_path, vec!["source/".to_string()], None);
838
+
839
+ assert!(result.is_err(), "should reject excessively long paths");
840
+ assert!(
841
+ result.unwrap_err().to_string().contains("maximum length"),
842
+ "error message should mention length limit"
843
+ );
844
+ }
845
+
846
+ #[test]
847
+ fn test_create_archive_sync_rejects_excessively_long_source_path() {
848
+ let long_path = "x".repeat(5000);
849
+ let result = create_archive_sync("/tmp/output.tar".to_string(), vec![long_path], None);
850
+
851
+ assert!(result.is_err(), "should reject excessively long paths");
852
+ assert!(
853
+ result.unwrap_err().to_string().contains("maximum length"),
854
+ "error message should mention length limit"
855
+ );
856
+ }
857
+
858
+ #[test]
859
+ fn test_create_archive_sync_accepts_empty_sources_array() {
860
+ // Empty sources array should pass boundary validation
861
+ let result = create_archive_sync("/tmp/output.tar".to_string(), vec![], None);
862
+
863
+ // If it fails, ensure it's not a boundary path validation error
864
+ if let Err(e) = result {
865
+ let err_msg = e.to_string();
866
+ assert!(
867
+ !err_msg.contains("null bytes") && !err_msg.contains("maximum length"),
868
+ "should not fail on boundary path validation, got: {}",
869
+ err_msg
870
+ );
871
+ }
872
+ }
873
+
874
+ // CR-004: list_archive path validation tests
875
+ #[tokio::test]
876
+ async fn test_list_archive_rejects_null_byte_in_archive_path() {
877
+ let result = list_archive("/tmp/test\0malicious.tar".to_string(), None).await;
878
+
879
+ assert!(result.is_err(), "should reject null bytes in archive path");
880
+ assert!(
881
+ result.unwrap_err().to_string().contains("null bytes"),
882
+ "error message should mention null bytes"
883
+ );
884
+ }
885
+
886
+ #[tokio::test]
887
+ async fn test_list_archive_rejects_excessively_long_archive_path() {
888
+ let long_path = "x".repeat(5000);
889
+ let result = list_archive(long_path, None).await;
890
+
891
+ assert!(result.is_err(), "should reject excessively long paths");
892
+ assert!(
893
+ result.unwrap_err().to_string().contains("maximum length"),
894
+ "error message should mention length limit"
895
+ );
896
+ }
897
+
898
+ #[test]
899
+ fn test_list_archive_sync_rejects_null_byte_in_archive_path() {
900
+ let result = list_archive_sync("/tmp/test\0malicious.tar".to_string(), None);
901
+
902
+ assert!(result.is_err(), "should reject null bytes in archive path");
903
+ assert!(
904
+ result.unwrap_err().to_string().contains("null bytes"),
905
+ "error message should mention null bytes"
906
+ );
907
+ }
908
+
909
+ #[test]
910
+ fn test_list_archive_sync_rejects_excessively_long_archive_path() {
911
+ let long_path = "x".repeat(5000);
912
+ let result = list_archive_sync(long_path, None);
913
+
914
+ assert!(result.is_err(), "should reject excessively long paths");
915
+ assert!(
916
+ result.unwrap_err().to_string().contains("maximum length"),
917
+ "error message should mention length limit"
918
+ );
919
+ }
920
+
921
+ // CR-004: verify_archive path validation tests
922
+ #[tokio::test]
923
+ async fn test_verify_archive_rejects_null_byte_in_archive_path() {
924
+ let result = verify_archive("/tmp/test\0malicious.tar".to_string(), None).await;
925
+
926
+ assert!(result.is_err(), "should reject null bytes in archive path");
927
+ assert!(
928
+ result.unwrap_err().to_string().contains("null bytes"),
929
+ "error message should mention null bytes"
930
+ );
931
+ }
932
+
933
+ #[tokio::test]
934
+ async fn test_verify_archive_rejects_excessively_long_archive_path() {
935
+ let long_path = "x".repeat(5000);
936
+ let result = verify_archive(long_path, None).await;
937
+
938
+ assert!(result.is_err(), "should reject excessively long paths");
939
+ assert!(
940
+ result.unwrap_err().to_string().contains("maximum length"),
941
+ "error message should mention length limit"
942
+ );
943
+ }
944
+
945
+ #[test]
946
+ fn test_verify_archive_sync_rejects_null_byte_in_archive_path() {
947
+ let result = verify_archive_sync("/tmp/test\0malicious.tar".to_string(), None);
948
+
949
+ assert!(result.is_err(), "should reject null bytes in archive path");
950
+ assert!(
951
+ result.unwrap_err().to_string().contains("null bytes"),
952
+ "error message should mention null bytes"
953
+ );
954
+ }
955
+
956
+ #[test]
957
+ fn test_verify_archive_sync_rejects_excessively_long_archive_path() {
958
+ let long_path = "x".repeat(5000);
959
+ let result = verify_archive_sync(long_path, None);
960
+
961
+ assert!(result.is_err(), "should reject excessively long paths");
962
+ assert!(
963
+ result.unwrap_err().to_string().contains("maximum length"),
964
+ "error message should mention length limit"
965
+ );
966
+ }
428
967
  }