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/report.rs CHANGED
@@ -42,6 +42,201 @@ impl From<CoreReport> for ExtractionReport {
42
42
  }
43
43
  }
44
44
 
45
+ /// Report of an archive creation operation.
46
+ ///
47
+ /// Contains statistics and metadata about the creation process.
48
+ #[napi(object)]
49
+ #[derive(Debug, Clone)]
50
+ pub struct CreationReport {
51
+ /// Number of files added to the archive.
52
+ pub files_added: u32,
53
+ /// Number of directories added to the archive.
54
+ pub directories_added: u32,
55
+ /// Number of symlinks added to the archive.
56
+ pub symlinks_added: u32,
57
+ /// Total bytes written to the archive (uncompressed).
58
+ pub bytes_written: i64,
59
+ /// Total bytes in the final archive (compressed).
60
+ pub bytes_compressed: i64,
61
+ /// Duration of the creation operation in milliseconds.
62
+ pub duration_ms: i64,
63
+ /// Number of files skipped (due to filters or errors).
64
+ pub files_skipped: u32,
65
+ /// Warnings generated during creation.
66
+ pub warnings: Vec<String>,
67
+ }
68
+
69
+ impl From<exarch_core::creation::CreationReport> for CreationReport {
70
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
71
+ fn from(report: exarch_core::creation::CreationReport) -> Self {
72
+ Self {
73
+ files_added: report.files_added.min(u32::MAX as usize) as u32,
74
+ directories_added: report.directories_added.min(u32::MAX as usize) as u32,
75
+ symlinks_added: report.symlinks_added.min(u32::MAX as usize) as u32,
76
+ bytes_written: report.bytes_written.min(i64::MAX as u64) as i64,
77
+ bytes_compressed: report.bytes_compressed.min(i64::MAX as u64) as i64,
78
+ duration_ms: report.duration.as_millis().min(i64::MAX as u128) as i64,
79
+ files_skipped: report.files_skipped.min(u32::MAX as usize) as u32,
80
+ warnings: report.warnings,
81
+ }
82
+ }
83
+ }
84
+
85
+ /// Complete manifest of archive contents.
86
+ ///
87
+ /// Generated by `listArchive()`, contains metadata about all entries
88
+ /// without extracting them to disk.
89
+ #[napi(object)]
90
+ #[derive(Debug, Clone)]
91
+ pub struct ArchiveManifest {
92
+ /// All entries in the archive (files, dirs, symlinks, hardlinks).
93
+ pub entries: Vec<ArchiveEntry>,
94
+ /// Total number of entries.
95
+ pub total_entries: u32,
96
+ /// Total uncompressed size in bytes.
97
+ pub total_size: i64,
98
+ /// Archive format (e.g., `TarGz`, `Zip`).
99
+ pub format: String,
100
+ }
101
+
102
+ impl From<exarch_core::inspection::ArchiveManifest> for ArchiveManifest {
103
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
104
+ fn from(manifest: exarch_core::inspection::ArchiveManifest) -> Self {
105
+ Self {
106
+ entries: manifest
107
+ .entries
108
+ .into_iter()
109
+ .map(ArchiveEntry::from)
110
+ .collect(),
111
+ total_entries: manifest.total_entries.min(u32::MAX as usize) as u32,
112
+ total_size: manifest.total_size.min(i64::MAX as u64) as i64,
113
+ format: format!("{:?}", manifest.format),
114
+ }
115
+ }
116
+ }
117
+
118
+ /// Single entry in archive manifest.
119
+ ///
120
+ /// Contains metadata about a file, directory, symlink, or hardlink
121
+ /// without extracting it to disk.
122
+ #[napi(object)]
123
+ #[derive(Debug, Clone)]
124
+ pub struct ArchiveEntry {
125
+ /// Entry path (relative, as stored in archive).
126
+ pub path: String,
127
+ /// Entry type ("File", "Directory", "Symlink", "Hardlink").
128
+ pub entry_type: String,
129
+ /// Uncompressed size in bytes (0 for directories).
130
+ pub size: i64,
131
+ /// Compressed size in bytes (if available, ZIP only).
132
+ pub compressed_size: Option<i64>,
133
+ /// File permissions (Unix mode).
134
+ pub mode: Option<u32>,
135
+ /// Modification time (milliseconds since Unix epoch).
136
+ pub modified: Option<i64>,
137
+ /// Symlink target (if `entry_type` is "Symlink").
138
+ pub symlink_target: Option<String>,
139
+ /// Hardlink target (if `entry_type` is "Hardlink").
140
+ pub hardlink_target: Option<String>,
141
+ }
142
+
143
+ impl From<exarch_core::inspection::ArchiveEntry> for ArchiveEntry {
144
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
145
+ fn from(entry: exarch_core::inspection::ArchiveEntry) -> Self {
146
+ Self {
147
+ path: entry.path.to_string_lossy().into_owned(),
148
+ entry_type: entry.entry_type.to_string(),
149
+ size: entry.size.min(i64::MAX as u64) as i64,
150
+ compressed_size: entry.compressed_size.map(|s| s.min(i64::MAX as u64) as i64),
151
+ mode: entry.mode,
152
+ modified: entry.modified.and_then(|t| {
153
+ t.duration_since(std::time::UNIX_EPOCH)
154
+ .ok()
155
+ .map(|d| d.as_millis().min(i64::MAX as u128) as i64)
156
+ }),
157
+ symlink_target: entry
158
+ .symlink_target
159
+ .map(|p| p.to_string_lossy().into_owned()),
160
+ hardlink_target: entry
161
+ .hardlink_target
162
+ .map(|p| p.to_string_lossy().into_owned()),
163
+ }
164
+ }
165
+ }
166
+
167
+ /// Result of archive verification.
168
+ ///
169
+ /// Generated by `verifyArchive()`, contains security and integrity checks
170
+ /// performed without extracting files to disk.
171
+ #[napi(object)]
172
+ #[derive(Debug, Clone)]
173
+ pub struct VerificationReport {
174
+ /// Overall verification status ("Pass", "Fail", "Warning").
175
+ pub status: String,
176
+ /// Integrity check result ("Pass", "Fail", "Warning", "Skipped").
177
+ pub integrity_status: String,
178
+ /// Security check result ("Pass", "Fail", "Warning", "Skipped").
179
+ pub security_status: String,
180
+ /// List of all issues found (sorted by severity).
181
+ pub issues: Vec<VerificationIssue>,
182
+ /// Total entries scanned.
183
+ pub total_entries: u32,
184
+ /// Entries flagged as suspicious.
185
+ pub suspicious_entries: u32,
186
+ /// Total uncompressed size.
187
+ pub total_size: i64,
188
+ /// Archive format (e.g., `TarGz`, `Zip`).
189
+ pub format: String,
190
+ }
191
+
192
+ impl From<exarch_core::inspection::VerificationReport> for VerificationReport {
193
+ #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
194
+ fn from(report: exarch_core::inspection::VerificationReport) -> Self {
195
+ Self {
196
+ status: report.status.to_string(),
197
+ integrity_status: report.integrity_status.to_string(),
198
+ security_status: report.security_status.to_string(),
199
+ issues: report
200
+ .issues
201
+ .into_iter()
202
+ .map(VerificationIssue::from)
203
+ .collect(),
204
+ total_entries: report.total_entries.min(u32::MAX as usize) as u32,
205
+ suspicious_entries: report.suspicious_entries.min(u32::MAX as usize) as u32,
206
+ total_size: report.total_size.min(i64::MAX as u64) as i64,
207
+ format: format!("{:?}", report.format),
208
+ }
209
+ }
210
+ }
211
+
212
+ /// Single verification issue.
213
+ #[napi(object)]
214
+ #[derive(Debug, Clone)]
215
+ pub struct VerificationIssue {
216
+ /// Issue severity level ("Critical", "High", "Medium", "Low", "Info").
217
+ pub severity: String,
218
+ /// Issue category (`PathTraversal`, `SymlinkEscape`, etc.).
219
+ pub category: String,
220
+ /// Entry path that triggered issue (if applicable).
221
+ pub entry_path: Option<String>,
222
+ /// Human-readable description.
223
+ pub message: String,
224
+ /// Optional context (compression ratio, target path, etc.).
225
+ pub context: Option<String>,
226
+ }
227
+
228
+ impl From<exarch_core::inspection::VerificationIssue> for VerificationIssue {
229
+ fn from(issue: exarch_core::inspection::VerificationIssue) -> Self {
230
+ Self {
231
+ severity: issue.severity.to_string(),
232
+ category: issue.category.to_string(),
233
+ entry_path: issue.entry_path.map(|p| p.to_string_lossy().into_owned()),
234
+ message: issue.message,
235
+ context: issue.context,
236
+ }
237
+ }
238
+ }
239
+
45
240
  #[cfg(test)]
46
241
  #[allow(clippy::unwrap_used, clippy::expect_used)]
47
242
  mod tests {
@@ -173,4 +368,347 @@ mod tests {
173
368
  "third warning should be at index 2"
174
369
  );
175
370
  }
371
+
372
+ // CreationReport tests
373
+ #[test]
374
+ fn test_creation_report_conversion() {
375
+ let mut core_report = exarch_core::creation::CreationReport::new();
376
+ core_report.files_added = 10;
377
+ core_report.directories_added = 5;
378
+ core_report.symlinks_added = 2;
379
+ core_report.bytes_written = 1024;
380
+ core_report.bytes_compressed = 512;
381
+ core_report.duration = std::time::Duration::from_millis(500);
382
+ core_report.files_skipped = 1;
383
+ core_report.add_warning("Test warning".to_string());
384
+
385
+ let report = CreationReport::from(core_report);
386
+
387
+ assert_eq!(report.files_added, 10);
388
+ assert_eq!(report.directories_added, 5);
389
+ assert_eq!(report.symlinks_added, 2);
390
+ assert_eq!(report.bytes_written, 1024);
391
+ assert_eq!(report.bytes_compressed, 512);
392
+ assert_eq!(report.duration_ms, 500);
393
+ assert_eq!(report.files_skipped, 1);
394
+ assert_eq!(report.warnings.len(), 1);
395
+ assert_eq!(report.warnings[0], "Test warning");
396
+ }
397
+
398
+ // ArchiveEntry tests
399
+ #[test]
400
+ fn test_archive_entry_conversion() {
401
+ use std::path::PathBuf;
402
+ use std::time::UNIX_EPOCH;
403
+
404
+ let core_entry = exarch_core::inspection::ArchiveEntry {
405
+ path: PathBuf::from("test/file.txt"),
406
+ entry_type: exarch_core::inspection::ManifestEntryType::File,
407
+ size: 1024,
408
+ compressed_size: Some(512),
409
+ mode: Some(0o644),
410
+ modified: Some(UNIX_EPOCH + std::time::Duration::from_secs(1000)),
411
+ symlink_target: None,
412
+ hardlink_target: None,
413
+ };
414
+
415
+ let entry = ArchiveEntry::from(core_entry);
416
+
417
+ assert_eq!(entry.path, "test/file.txt");
418
+ assert_eq!(entry.entry_type, "File");
419
+ assert_eq!(entry.size, 1024);
420
+ assert_eq!(entry.compressed_size, Some(512));
421
+ assert_eq!(entry.mode, Some(0o644));
422
+ assert_eq!(entry.modified, Some(1_000_000));
423
+ assert_eq!(entry.symlink_target, None);
424
+ assert_eq!(entry.hardlink_target, None);
425
+ }
426
+
427
+ // ArchiveManifest tests
428
+ #[test]
429
+ fn test_archive_manifest_conversion() {
430
+ use exarch_core::formats::detect::ArchiveType;
431
+
432
+ let mut core_manifest = exarch_core::inspection::ArchiveManifest::new(ArchiveType::TarGz);
433
+ core_manifest.total_entries = 10;
434
+ core_manifest.total_size = 5000;
435
+
436
+ let manifest = ArchiveManifest::from(core_manifest);
437
+
438
+ assert_eq!(manifest.total_entries, 10);
439
+ assert_eq!(manifest.total_size, 5000);
440
+ assert!(manifest.format.contains("TarGz"));
441
+ }
442
+
443
+ // VerificationReport tests
444
+ #[test]
445
+ fn test_verification_report_conversion() {
446
+ use exarch_core::formats::detect::ArchiveType;
447
+ use exarch_core::inspection::CheckStatus;
448
+ use exarch_core::inspection::VerificationStatus;
449
+
450
+ let core_report = exarch_core::inspection::VerificationReport {
451
+ status: VerificationStatus::Pass,
452
+ integrity_status: CheckStatus::Pass,
453
+ security_status: CheckStatus::Pass,
454
+ issues: vec![],
455
+ total_entries: 10,
456
+ suspicious_entries: 0,
457
+ total_size: 5000,
458
+ format: ArchiveType::TarGz,
459
+ };
460
+
461
+ let report = VerificationReport::from(core_report);
462
+
463
+ assert_eq!(report.status, "PASS");
464
+ assert_eq!(report.integrity_status, "OK");
465
+ assert_eq!(report.security_status, "OK");
466
+ assert_eq!(report.total_entries, 10);
467
+ assert_eq!(report.suspicious_entries, 0);
468
+ assert_eq!(report.total_size, 5000);
469
+ }
470
+
471
+ // VerificationIssue tests
472
+ #[test]
473
+ fn test_verification_issue_conversion() {
474
+ use exarch_core::inspection::IssueCategory;
475
+ use exarch_core::inspection::IssueSeverity;
476
+ use std::path::PathBuf;
477
+
478
+ let core_issue = exarch_core::inspection::VerificationIssue {
479
+ severity: IssueSeverity::Critical,
480
+ category: IssueCategory::PathTraversal,
481
+ entry_path: Some(PathBuf::from("../etc/passwd")),
482
+ message: "Path traversal detected".to_string(),
483
+ context: Some("Attempt to escape directory".to_string()),
484
+ };
485
+
486
+ let issue = VerificationIssue::from(core_issue);
487
+
488
+ assert_eq!(issue.severity, "CRITICAL");
489
+ assert_eq!(issue.category, "Path Traversal");
490
+ assert_eq!(issue.entry_path, Some("../etc/passwd".to_string()));
491
+ assert_eq!(issue.message, "Path traversal detected");
492
+ assert_eq!(
493
+ issue.context,
494
+ Some("Attempt to escape directory".to_string())
495
+ );
496
+ }
497
+
498
+ // CR-004: Overflow/saturating conversion tests
499
+ #[test]
500
+ fn test_extraction_report_saturates_files_extracted_at_u32_max() {
501
+ let mut core_report = CoreReport::new();
502
+ // Set to value > u32::MAX
503
+ core_report.files_extracted = (u32::MAX as usize) + 1000;
504
+
505
+ let report = ExtractionReport::from(core_report);
506
+
507
+ assert_eq!(
508
+ report.files_extracted,
509
+ u32::MAX,
510
+ "files_extracted should saturate to u32::MAX"
511
+ );
512
+ }
513
+
514
+ #[test]
515
+ fn test_extraction_report_saturates_bytes_written_at_i64_max() {
516
+ let mut core_report = CoreReport::new();
517
+ // Set to value > i64::MAX
518
+ core_report.bytes_written = u64::MAX;
519
+
520
+ let report = ExtractionReport::from(core_report);
521
+
522
+ assert_eq!(
523
+ report.bytes_written,
524
+ i64::MAX,
525
+ "bytes_written should saturate to i64::MAX"
526
+ );
527
+ }
528
+
529
+ #[test]
530
+ fn test_extraction_report_saturates_duration_ms_at_i64_max() {
531
+ let mut core_report = CoreReport::new();
532
+ // Create a very large duration that exceeds i64::MAX milliseconds
533
+ // i64::MAX ms is about 292 million years, so use u64::MAX seconds
534
+ core_report.duration = Duration::from_secs(u64::MAX);
535
+
536
+ let report = ExtractionReport::from(core_report);
537
+
538
+ assert_eq!(
539
+ report.duration_ms,
540
+ i64::MAX,
541
+ "duration_ms should saturate to i64::MAX"
542
+ );
543
+ }
544
+
545
+ #[test]
546
+ fn test_creation_report_saturates_files_added_at_u32_max() {
547
+ let mut core_report = exarch_core::creation::CreationReport::new();
548
+ core_report.files_added = (u32::MAX as usize) + 1000;
549
+
550
+ let report = CreationReport::from(core_report);
551
+
552
+ assert_eq!(
553
+ report.files_added,
554
+ u32::MAX,
555
+ "files_added should saturate to u32::MAX"
556
+ );
557
+ }
558
+
559
+ #[test]
560
+ fn test_creation_report_saturates_bytes_written_at_i64_max() {
561
+ let mut core_report = exarch_core::creation::CreationReport::new();
562
+ core_report.bytes_written = u64::MAX;
563
+
564
+ let report = CreationReport::from(core_report);
565
+
566
+ assert_eq!(
567
+ report.bytes_written,
568
+ i64::MAX,
569
+ "bytes_written should saturate to i64::MAX"
570
+ );
571
+ }
572
+
573
+ #[test]
574
+ fn test_creation_report_saturates_bytes_compressed_at_i64_max() {
575
+ let mut core_report = exarch_core::creation::CreationReport::new();
576
+ core_report.bytes_compressed = u64::MAX;
577
+
578
+ let report = CreationReport::from(core_report);
579
+
580
+ assert_eq!(
581
+ report.bytes_compressed,
582
+ i64::MAX,
583
+ "bytes_compressed should saturate to i64::MAX"
584
+ );
585
+ }
586
+
587
+ #[test]
588
+ fn test_archive_manifest_saturates_total_entries_at_u32_max() {
589
+ use exarch_core::formats::detect::ArchiveType;
590
+
591
+ let mut core_manifest = exarch_core::inspection::ArchiveManifest::new(ArchiveType::TarGz);
592
+ core_manifest.total_entries = (u32::MAX as usize) + 1000;
593
+
594
+ let manifest = ArchiveManifest::from(core_manifest);
595
+
596
+ assert_eq!(
597
+ manifest.total_entries,
598
+ u32::MAX,
599
+ "total_entries should saturate to u32::MAX"
600
+ );
601
+ }
602
+
603
+ #[test]
604
+ fn test_archive_manifest_saturates_total_size_at_i64_max() {
605
+ use exarch_core::formats::detect::ArchiveType;
606
+
607
+ let mut core_manifest = exarch_core::inspection::ArchiveManifest::new(ArchiveType::TarGz);
608
+ core_manifest.total_size = u64::MAX;
609
+
610
+ let manifest = ArchiveManifest::from(core_manifest);
611
+
612
+ assert_eq!(
613
+ manifest.total_size,
614
+ i64::MAX,
615
+ "total_size should saturate to i64::MAX"
616
+ );
617
+ }
618
+
619
+ #[test]
620
+ fn test_archive_entry_saturates_size_at_i64_max() {
621
+ use std::path::PathBuf;
622
+
623
+ let core_entry = exarch_core::inspection::ArchiveEntry {
624
+ path: PathBuf::from("test.txt"),
625
+ entry_type: exarch_core::inspection::ManifestEntryType::File,
626
+ size: u64::MAX,
627
+ compressed_size: None,
628
+ mode: None,
629
+ modified: None,
630
+ symlink_target: None,
631
+ hardlink_target: None,
632
+ };
633
+
634
+ let entry = ArchiveEntry::from(core_entry);
635
+
636
+ assert_eq!(entry.size, i64::MAX, "size should saturate to i64::MAX");
637
+ }
638
+
639
+ #[test]
640
+ fn test_archive_entry_saturates_compressed_size_at_i64_max() {
641
+ use std::path::PathBuf;
642
+
643
+ let core_entry = exarch_core::inspection::ArchiveEntry {
644
+ path: PathBuf::from("test.txt"),
645
+ entry_type: exarch_core::inspection::ManifestEntryType::File,
646
+ size: 1024,
647
+ compressed_size: Some(u64::MAX),
648
+ mode: None,
649
+ modified: None,
650
+ symlink_target: None,
651
+ hardlink_target: None,
652
+ };
653
+
654
+ let entry = ArchiveEntry::from(core_entry);
655
+
656
+ assert_eq!(
657
+ entry.compressed_size,
658
+ Some(i64::MAX),
659
+ "compressed_size should saturate to i64::MAX"
660
+ );
661
+ }
662
+
663
+ #[test]
664
+ fn test_verification_report_saturates_total_entries_at_u32_max() {
665
+ use exarch_core::formats::detect::ArchiveType;
666
+ use exarch_core::inspection::CheckStatus;
667
+ use exarch_core::inspection::VerificationStatus;
668
+
669
+ let core_report = exarch_core::inspection::VerificationReport {
670
+ status: VerificationStatus::Pass,
671
+ integrity_status: CheckStatus::Pass,
672
+ security_status: CheckStatus::Pass,
673
+ issues: vec![],
674
+ total_entries: (u32::MAX as usize) + 1000,
675
+ suspicious_entries: 0,
676
+ total_size: 5000,
677
+ format: ArchiveType::TarGz,
678
+ };
679
+
680
+ let report = VerificationReport::from(core_report);
681
+
682
+ assert_eq!(
683
+ report.total_entries,
684
+ u32::MAX,
685
+ "total_entries should saturate to u32::MAX"
686
+ );
687
+ }
688
+
689
+ #[test]
690
+ fn test_verification_report_saturates_suspicious_entries_at_u32_max() {
691
+ use exarch_core::formats::detect::ArchiveType;
692
+ use exarch_core::inspection::CheckStatus;
693
+ use exarch_core::inspection::VerificationStatus;
694
+
695
+ let core_report = exarch_core::inspection::VerificationReport {
696
+ status: VerificationStatus::Fail,
697
+ integrity_status: CheckStatus::Pass,
698
+ security_status: CheckStatus::Fail,
699
+ issues: vec![],
700
+ total_entries: 100,
701
+ suspicious_entries: (u32::MAX as usize) + 1000,
702
+ total_size: 5000,
703
+ format: ArchiveType::TarGz,
704
+ };
705
+
706
+ let report = VerificationReport::from(core_report);
707
+
708
+ assert_eq!(
709
+ report.suspicious_entries,
710
+ u32::MAX,
711
+ "suspicious_entries should saturate to u32::MAX"
712
+ );
713
+ }
176
714
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Tests for archive creation functions
3
+ */
4
+ const { describe, it, beforeEach } = 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 {
10
+ createArchive,
11
+ createArchiveSync,
12
+ listArchiveSync,
13
+ CreationConfig,
14
+ } = require('../index.js');
15
+
16
+ function createTempDir() {
17
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'exarch-test-'));
18
+ }
19
+
20
+ function createTestFiles(dir) {
21
+ fs.writeFileSync(path.join(dir, 'file1.txt'), 'Content of file 1');
22
+ fs.writeFileSync(path.join(dir, 'file2.txt'), 'Content of file 2');
23
+
24
+ const subdir = path.join(dir, 'subdir');
25
+ fs.mkdirSync(subdir);
26
+ fs.writeFileSync(path.join(subdir, 'nested.txt'), 'Nested file content');
27
+ }
28
+
29
+ describe('createArchive (async)', () => {
30
+ let tempDir;
31
+ let sourceDir;
32
+ let outputPath;
33
+
34
+ beforeEach(() => {
35
+ tempDir = createTempDir();
36
+ sourceDir = path.join(tempDir, 'source');
37
+ fs.mkdirSync(sourceDir);
38
+ createTestFiles(sourceDir);
39
+ outputPath = path.join(tempDir, 'output.tar.gz');
40
+ });
41
+
42
+ it('should create a tar.gz archive', async () => {
43
+ const report = await createArchive(outputPath, [sourceDir]);
44
+
45
+ assert.ok(report.filesAdded >= 3);
46
+ assert.ok(report.bytesWritten > 0);
47
+ assert.ok(report.durationMs >= 0);
48
+ assert.ok(fs.existsSync(outputPath));
49
+ assert.ok(fs.statSync(outputPath).size > 0);
50
+ });
51
+
52
+ it('should create archive from multiple sources', async () => {
53
+ const file1 = path.join(sourceDir, 'file1.txt');
54
+ const file2 = path.join(sourceDir, 'file2.txt');
55
+
56
+ const report = await createArchive(outputPath, [file1, file2]);
57
+
58
+ assert.strictEqual(report.filesAdded, 2);
59
+ });
60
+
61
+ it('should accept custom CreationConfig', async () => {
62
+ const config = new CreationConfig();
63
+ config.setCompressionLevel(9);
64
+ config.setIncludeHidden(false);
65
+
66
+ const report = await createArchive(outputPath, [sourceDir], config);
67
+
68
+ assert.ok(report.filesAdded > 0);
69
+ });
70
+
71
+ it('should throw on invalid source path', async () => {
72
+ await assert.rejects(
73
+ createArchive(outputPath, ['/nonexistent/path']),
74
+ /IO_ERROR|No such file|not found/i
75
+ );
76
+ });
77
+
78
+ it('should report warnings array', async () => {
79
+ const report = await createArchive(outputPath, [sourceDir]);
80
+
81
+ assert.ok(Array.isArray(report.warnings));
82
+ });
83
+ });
84
+
85
+ describe('createArchiveSync', () => {
86
+ let tempDir;
87
+ let sourceDir;
88
+ let outputPath;
89
+
90
+ beforeEach(() => {
91
+ tempDir = createTempDir();
92
+ sourceDir = path.join(tempDir, 'source');
93
+ fs.mkdirSync(sourceDir);
94
+ createTestFiles(sourceDir);
95
+ outputPath = path.join(tempDir, 'output.tar.gz');
96
+ });
97
+
98
+ it('should create archive synchronously', () => {
99
+ const report = createArchiveSync(outputPath, [sourceDir]);
100
+
101
+ assert.ok(report.filesAdded >= 3);
102
+ assert.ok(fs.existsSync(outputPath));
103
+ });
104
+
105
+ it('should accept custom CreationConfig', () => {
106
+ const config = new CreationConfig();
107
+ config.setCompressionLevel(1);
108
+ const report = createArchiveSync(outputPath, [sourceDir], config);
109
+
110
+ assert.ok(report.filesAdded > 0);
111
+ });
112
+
113
+ it('should support exclude patterns', () => {
114
+ fs.writeFileSync(path.join(sourceDir, 'debug.log'), 'log content');
115
+
116
+ const config = new CreationConfig();
117
+ config.addExcludePattern('*.log');
118
+ createArchiveSync(outputPath, [sourceDir], config);
119
+
120
+ const manifest = listArchiveSync(outputPath);
121
+ const logEntries = manifest.entries.filter((e) => e.path.endsWith('.log'));
122
+ assert.strictEqual(logEntries.length, 0);
123
+ });
124
+ });