Skip to main content

exarch_core/creation/
tar.rs

1//! TAR archive creation with multiple compression formats.
2//!
3//! This module provides functions for creating TAR archives with various
4//! compression options: uncompressed, gzip, bzip2, xz, and zstd.
5
6use crate::ProgressCallback;
7use crate::Result;
8use crate::creation::compression::compression_level_to_bzip2;
9use crate::creation::compression::compression_level_to_flate2;
10use crate::creation::compression::compression_level_to_xz;
11use crate::creation::compression::compression_level_to_zstd;
12use crate::creation::config::CreationConfig;
13use crate::creation::progress::ProgressReader;
14use crate::creation::progress::ProgressTracker;
15use crate::creation::report::CreationReport;
16use crate::creation::walker::EntryType;
17use crate::creation::walker::collect_entries;
18use crate::io::CountingWriter;
19use std::fs::File;
20use std::io::Write;
21use std::path::Path;
22use tar::Builder;
23use tar::Header;
24
25/// Creates an uncompressed TAR archive with progress reporting.
26///
27/// This function provides real-time progress updates during archive creation
28/// through callback functions. Useful for displaying progress bars or logging
29/// in interactive applications.
30///
31/// # Parameters
32///
33/// - `output`: Path where the TAR archive will be created
34/// - `sources`: Slice of source paths to include in the archive
35/// - `config`: Configuration controlling filtering, permissions, and archiving
36///   behavior
37/// - `progress`: Mutable reference to a progress callback implementation
38///
39/// # Progress Callbacks
40///
41/// The `progress` callback receives four types of events:
42///
43/// 1. `on_entry_start`: Called before processing each file/directory
44/// 2. `on_bytes_written`: Called for each chunk of data written (typically
45///    every 64 KB)
46/// 3. `on_entry_complete`: Called after successfully processing an entry
47/// 4. `on_complete`: Called once when the entire archive is finished
48///
49/// Note: Callbacks are invoked frequently during large file processing. For
50/// better performance with very large files, consider batching updates.
51///
52/// # Examples
53///
54/// ```no_run
55/// use exarch_core::ProgressCallback;
56/// use exarch_core::creation::CreationConfig;
57/// use exarch_core::creation::tar::create_tar_with_progress;
58/// use std::path::Path;
59///
60/// struct SimpleProgress;
61///
62/// impl ProgressCallback for SimpleProgress {
63///     fn on_entry_start(&mut self, path: &Path, total: usize, current: usize) {
64///         println!("[{}/{}] Processing: {}", current, total, path.display());
65///     }
66///
67///     fn on_bytes_written(&mut self, bytes: u64) {
68///         // Called frequently - consider rate limiting
69///     }
70///
71///     fn on_entry_complete(&mut self, path: &Path) {
72///         println!("Completed: {}", path.display());
73///     }
74///
75///     fn on_complete(&mut self) {
76///         println!("Archive creation complete!");
77///     }
78/// }
79///
80/// let config = CreationConfig::default();
81/// let mut progress = SimpleProgress;
82/// let report = create_tar_with_progress(
83///     Path::new("output.tar"),
84///     &[Path::new("src")],
85///     &config,
86///     &mut progress,
87/// )?;
88/// # Ok::<(), exarch_core::ArchiveError>(())
89/// ```
90///
91/// # Errors
92///
93/// Returns an error if:
94/// - Source path does not exist
95/// - Output file cannot be created
96/// - I/O error during archive creation
97/// - File metadata cannot be read
98pub fn create_tar_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
99    output: P,
100    sources: &[Q],
101    config: &CreationConfig,
102    progress: &mut dyn ProgressCallback,
103) -> Result<CreationReport> {
104    let file = File::create(output.as_ref())?;
105    let (report, _) = create_tar_internal_with_progress(file, sources, config, progress)?;
106    Ok(report)
107}
108
109/// Creates a gzip-compressed TAR archive with progress reporting.
110///
111/// Identical to [`create_tar_with_progress`] but applies gzip compression.
112/// See that function for detailed documentation on progress callbacks and
113/// usage.
114///
115/// # Errors
116///
117/// Returns an error if output file cannot be created, compression fails, or I/O
118/// operations fail.
119pub fn create_tar_gz_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
120    output: P,
121    sources: &[Q],
122    config: &CreationConfig,
123    progress: &mut dyn ProgressCallback,
124) -> Result<CreationReport> {
125    let file = File::create(output.as_ref())?;
126    let level = compression_level_to_flate2(config.compression_level);
127    let encoder = flate2::write::GzEncoder::new(file, level);
128    let (report, _) = create_tar_internal_with_progress(encoder, sources, config, progress)?;
129    Ok(report)
130}
131
132/// Creates a bzip2-compressed TAR archive with progress reporting.
133///
134/// Identical to [`create_tar_with_progress`] but applies bzip2 compression.
135/// See that function for detailed documentation on progress callbacks and
136/// usage.
137///
138/// # Errors
139///
140/// Returns an error if output file cannot be created, compression fails, or I/O
141/// operations fail.
142pub fn create_tar_bz2_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
143    output: P,
144    sources: &[Q],
145    config: &CreationConfig,
146    progress: &mut dyn ProgressCallback,
147) -> Result<CreationReport> {
148    let file = File::create(output.as_ref())?;
149    let level = compression_level_to_bzip2(config.compression_level);
150    let encoder = bzip2::write::BzEncoder::new(file, level);
151    let (report, _) = create_tar_internal_with_progress(encoder, sources, config, progress)?;
152    Ok(report)
153}
154
155/// Creates an xz-compressed TAR archive with progress reporting.
156///
157/// Identical to [`create_tar_with_progress`] but applies xz compression.
158/// See that function for detailed documentation on progress callbacks and
159/// usage.
160///
161/// # Errors
162///
163/// Returns an error if output file cannot be created, compression fails, or I/O
164/// operations fail.
165pub fn create_tar_xz_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
166    output: P,
167    sources: &[Q],
168    config: &CreationConfig,
169    progress: &mut dyn ProgressCallback,
170) -> Result<CreationReport> {
171    let file = File::create(output.as_ref())?;
172    let level = compression_level_to_xz(config.compression_level);
173    let encoder = xz2::write::XzEncoder::new(file, level);
174    let (report, _) = create_tar_internal_with_progress(encoder, sources, config, progress)?;
175    Ok(report)
176}
177
178/// Creates a zstd-compressed TAR archive with progress reporting.
179///
180/// Identical to [`create_tar_with_progress`] but applies zstd compression.
181/// See that function for detailed documentation on progress callbacks and
182/// usage.
183///
184/// # Errors
185///
186/// Returns an error if output file cannot be created, compression fails, or I/O
187/// operations fail.
188pub fn create_tar_zst_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
189    output: P,
190    sources: &[Q],
191    config: &CreationConfig,
192    progress: &mut dyn ProgressCallback,
193) -> Result<CreationReport> {
194    let file = File::create(output.as_ref())?;
195    let level = compression_level_to_zstd(config.compression_level);
196    let mut encoder = zstd::Encoder::new(file, level)?;
197    encoder.include_checksum(true)?;
198
199    let (report, encoder) = create_tar_internal_with_progress(encoder, sources, config, progress)?;
200    encoder.finish()?;
201
202    Ok(report)
203}
204
205/// Internal function that creates TAR with any writer and progress reporting.
206///
207/// Returns `(report, writer)` so callers that wrap the writer (e.g. zstd
208/// encoder) can finalize it after all TAR data has been flushed.
209fn create_tar_internal_with_progress<W: Write, P: AsRef<Path>>(
210    writer: W,
211    sources: &[P],
212    config: &CreationConfig,
213    progress: &mut dyn ProgressCallback,
214) -> Result<(CreationReport, W)> {
215    let counting_writer = CountingWriter::new(writer);
216    let mut builder = Builder::new(counting_writer);
217    let mut report = CreationReport::default();
218    let start = std::time::Instant::now();
219
220    // Single-pass collection of entries (avoids double directory traversal)
221    let entries = collect_entries(sources, config)?;
222    let total_entries = entries.len();
223
224    let mut tracker = ProgressTracker::new(progress, total_entries);
225
226    for entry in &entries {
227        match &entry.entry_type {
228            EntryType::File => {
229                tracker.on_entry_start(&entry.archive_path);
230                add_file_to_tar_with_progress_impl(
231                    &mut builder,
232                    &entry.path,
233                    &entry.archive_path,
234                    config,
235                    &mut report,
236                    tracker.callback(),
237                )?;
238                tracker.on_entry_complete(&entry.archive_path);
239            }
240            EntryType::Directory => {
241                tracker.on_entry_start(&entry.archive_path);
242                report.directories_added += 1;
243                tracker.on_entry_complete(&entry.archive_path);
244            }
245            EntryType::Symlink { target } => {
246                tracker.on_entry_start(&entry.archive_path);
247                if config.follow_symlinks {
248                    add_file_to_tar_with_progress_impl(
249                        &mut builder,
250                        &entry.path,
251                        &entry.archive_path,
252                        config,
253                        &mut report,
254                        tracker.callback(),
255                    )?;
256                } else {
257                    add_symlink_to_tar(&mut builder, &entry.archive_path, target, &mut report)?;
258                }
259                tracker.on_entry_complete(&entry.archive_path);
260            }
261        }
262    }
263
264    // Finish writing TAR
265    builder.finish()?;
266
267    let mut counting_writer = builder.into_inner()?;
268    counting_writer.flush()?;
269
270    report.bytes_compressed = counting_writer.total_bytes();
271    report.duration = start.elapsed();
272
273    tracker.on_complete();
274
275    Ok((report, counting_writer.into_inner()))
276}
277
278/// Adds a single file to the TAR archive with progress reporting.
279fn add_file_to_tar_with_progress_impl<W: Write>(
280    builder: &mut Builder<W>,
281    file_path: &Path,
282    archive_path: &Path,
283    config: &CreationConfig,
284    report: &mut CreationReport,
285    progress: &mut dyn ProgressCallback,
286) -> Result<()> {
287    let file = File::open(file_path)?;
288    let metadata = file.metadata()?;
289    let size = metadata.len();
290
291    let mut header = Header::new_gnu();
292    header.set_size(size);
293    header.set_cksum();
294
295    if config.preserve_permissions {
296        set_permissions(&mut header, &metadata);
297    }
298
299    let mut tracked_file = ProgressReader::new(file, progress);
300    builder.append_data(&mut header, archive_path, &mut tracked_file)?;
301
302    report.files_added += 1;
303    report.bytes_written += size;
304
305    Ok(())
306}
307
308/// Adds a symlink to the TAR archive.
309#[cfg(unix)]
310fn add_symlink_to_tar<W: Write>(
311    builder: &mut Builder<W>,
312    link_path: &Path,
313    target: &Path,
314    report: &mut CreationReport,
315) -> Result<()> {
316    let mut header = Header::new_gnu();
317    header.set_entry_type(tar::EntryType::Symlink);
318    header.set_size(0);
319    header.set_cksum();
320
321    builder.append_link(&mut header, link_path, target)?;
322
323    report.symlinks_added += 1;
324
325    Ok(())
326}
327
328#[cfg(not(unix))]
329fn add_symlink_to_tar<W: Write>(
330    _builder: &mut Builder<W>,
331    _link_path: &Path,
332    _target: &Path,
333    report: &mut CreationReport,
334) -> Result<()> {
335    // On non-Unix platforms, skip symlinks
336    report.files_skipped += 1;
337    report.add_warning("Symlinks not supported on this platform");
338    Ok(())
339}
340
341/// Sets file permissions in TAR header from metadata.
342#[cfg(unix)]
343fn set_permissions(header: &mut Header, metadata: &std::fs::Metadata) {
344    use std::os::unix::fs::MetadataExt;
345    let mode = metadata.mode();
346    header.set_mode(mode);
347    header.set_uid(u64::from(metadata.uid()));
348    header.set_gid(u64::from(metadata.gid()));
349    // mtime can be negative for dates before epoch, clamp to 0
350    #[allow(clippy::cast_sign_loss)] // Intentional: clamped to non-negative
351    let mtime = metadata.mtime().max(0) as u64;
352    header.set_mtime(mtime);
353}
354
355#[cfg(not(unix))]
356fn set_permissions(header: &mut Header, metadata: &std::fs::Metadata) {
357    // On non-Unix platforms, set basic permissions
358    let mode = if metadata.permissions().readonly() {
359        0o444
360    } else {
361        0o644
362    };
363    header.set_mode(mode);
364
365    // Set modification time
366    if let Ok(modified) = metadata.modified() {
367        if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
368            header.set_mtime(duration.as_secs());
369        }
370    }
371}
372
373/// Format creator for uncompressed TAR archives.
374pub struct TarCreator;
375
376/// Format creator for gzip-compressed TAR archives.
377pub struct TarGzCreator;
378
379/// Format creator for bzip2-compressed TAR archives.
380pub struct TarBz2Creator;
381
382/// Format creator for xz-compressed TAR archives.
383pub struct TarXzCreator;
384
385/// Format creator for zstd-compressed TAR archives.
386pub struct TarZstCreator;
387
388impl crate::formats::traits::FormatCreator for TarCreator {
389    fn create(
390        &self,
391        output: &Path,
392        sources: &[&Path],
393        config: &CreationConfig,
394        progress: &mut dyn ProgressCallback,
395    ) -> crate::Result<crate::creation::CreationReport> {
396        create_tar_with_progress(output, sources, config, progress)
397    }
398
399    fn format_name(&self) -> &'static str {
400        "tar"
401    }
402}
403
404impl crate::formats::traits::FormatCreator for TarGzCreator {
405    fn create(
406        &self,
407        output: &Path,
408        sources: &[&Path],
409        config: &CreationConfig,
410        progress: &mut dyn ProgressCallback,
411    ) -> crate::Result<crate::creation::CreationReport> {
412        create_tar_gz_with_progress(output, sources, config, progress)
413    }
414
415    fn format_name(&self) -> &'static str {
416        "tar.gz"
417    }
418}
419
420impl crate::formats::traits::FormatCreator for TarBz2Creator {
421    fn create(
422        &self,
423        output: &Path,
424        sources: &[&Path],
425        config: &CreationConfig,
426        progress: &mut dyn ProgressCallback,
427    ) -> crate::Result<crate::creation::CreationReport> {
428        create_tar_bz2_with_progress(output, sources, config, progress)
429    }
430
431    fn format_name(&self) -> &'static str {
432        "tar.bz2"
433    }
434}
435
436impl crate::formats::traits::FormatCreator for TarXzCreator {
437    fn create(
438        &self,
439        output: &Path,
440        sources: &[&Path],
441        config: &CreationConfig,
442        progress: &mut dyn ProgressCallback,
443    ) -> crate::Result<crate::creation::CreationReport> {
444        create_tar_xz_with_progress(output, sources, config, progress)
445    }
446
447    fn format_name(&self) -> &'static str {
448        "tar.xz"
449    }
450}
451
452impl crate::formats::traits::FormatCreator for TarZstCreator {
453    fn create(
454        &self,
455        output: &Path,
456        sources: &[&Path],
457        config: &CreationConfig,
458        progress: &mut dyn ProgressCallback,
459    ) -> crate::Result<crate::creation::CreationReport> {
460        create_tar_zst_with_progress(output, sources, config, progress)
461    }
462
463    fn format_name(&self) -> &'static str {
464        "tar.zst"
465    }
466}
467
468#[cfg(test)]
469#[allow(clippy::unwrap_used)] // Allow unwrap in tests for brevity
470mod tests {
471    use super::*;
472    use crate::ArchiveError;
473    use crate::SecurityConfig;
474    use crate::api::create_archive;
475    use crate::api::extract_archive;
476    use crate::formats::detect::ArchiveType;
477    use std::fs;
478    use tempfile::TempDir;
479
480    #[test]
481    fn test_create_tar_single_file() {
482        let temp = TempDir::new().unwrap();
483        let output = temp.path().join("output.tar");
484
485        let source_dir = TempDir::new().unwrap();
486        fs::write(source_dir.path().join("test.txt"), "Hello TAR").unwrap();
487
488        let config = CreationConfig::default()
489            .with_exclude_patterns(vec![])
490            .with_include_hidden(true)
491            .with_format(Some(ArchiveType::Tar));
492
493        let report = create_archive(
494            &output,
495            &[source_dir.path().join("test.txt").as_path()],
496            &config,
497        )
498        .unwrap();
499
500        assert_eq!(report.files_added, 1);
501        assert!(report.bytes_written > 0);
502        assert!(output.exists());
503    }
504
505    #[test]
506    fn test_create_tar_directory() {
507        let temp = TempDir::new().unwrap();
508        let output = temp.path().join("output.tar");
509
510        let source_dir = TempDir::new().unwrap();
511        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
512        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
513        fs::create_dir(source_dir.path().join("subdir")).unwrap();
514        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
515
516        let config = CreationConfig::default()
517            .with_exclude_patterns(vec![])
518            .with_include_hidden(true)
519            .with_format(Some(ArchiveType::Tar));
520
521        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
522
523        assert_eq!(report.files_added, 3);
524        assert_eq!(report.directories_added, 2);
525        assert!(output.exists());
526    }
527
528    #[test]
529    fn test_create_tar_gz_compression() {
530        let temp = TempDir::new().unwrap();
531        let output = temp.path().join("output.tar.gz");
532
533        let source_dir = TempDir::new().unwrap();
534        fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
535
536        let config = CreationConfig::default()
537            .with_exclude_patterns(vec![])
538            .with_compression_level(9)
539            .unwrap()
540            .with_format(Some(ArchiveType::TarGz));
541
542        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
543
544        assert_eq!(report.files_added, 1);
545        assert!(output.exists());
546
547        let data = fs::read(&output).unwrap();
548        assert_eq!(&data[0..2], &[0x1f, 0x8b]); // gzip magic bytes
549    }
550
551    #[test]
552    fn test_create_tar_bz2_compression() {
553        let temp = TempDir::new().unwrap();
554        let output = temp.path().join("output.tar.bz2");
555
556        let source_dir = TempDir::new().unwrap();
557        fs::write(source_dir.path().join("test.txt"), "bzip2 test").unwrap();
558
559        let config = CreationConfig::default()
560            .with_exclude_patterns(vec![])
561            .with_format(Some(ArchiveType::TarBz2));
562
563        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
564
565        assert_eq!(report.files_added, 1);
566        assert!(output.exists());
567
568        let data = fs::read(&output).unwrap();
569        assert_eq!(&data[0..3], b"BZh"); // bzip2 magic bytes
570    }
571
572    #[test]
573    fn test_create_tar_xz_compression() {
574        let temp = TempDir::new().unwrap();
575        let output = temp.path().join("output.tar.xz");
576
577        let source_dir = TempDir::new().unwrap();
578        fs::write(source_dir.path().join("test.txt"), "xz test").unwrap();
579
580        let config = CreationConfig::default()
581            .with_exclude_patterns(vec![])
582            .with_format(Some(ArchiveType::TarXz));
583
584        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
585
586        assert_eq!(report.files_added, 1);
587        assert!(output.exists());
588
589        let data = fs::read(&output).unwrap();
590        assert_eq!(&data[0..6], &[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]); // xz magic bytes
591    }
592
593    #[test]
594    fn test_create_tar_zst_compression() {
595        let temp = TempDir::new().unwrap();
596        let output = temp.path().join("output.tar.zst");
597
598        let source_dir = TempDir::new().unwrap();
599        fs::write(source_dir.path().join("test.txt"), "zstd test").unwrap();
600
601        let config = CreationConfig::default()
602            .with_exclude_patterns(vec![])
603            .with_format(Some(ArchiveType::TarZst));
604
605        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
606
607        assert_eq!(report.files_added, 1);
608        assert!(output.exists());
609
610        let data = fs::read(&output).unwrap();
611        assert!(data.len() >= 4, "output file should have data");
612        assert_eq!(&data[0..4], &[0x28, 0xB5, 0x2F, 0xFD]); // zstd magic bytes
613    }
614
615    #[test]
616    fn test_create_tar_compression_levels() {
617        let temp = TempDir::new().unwrap();
618
619        let source_dir = TempDir::new().unwrap();
620        fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
621
622        for level in [1, 6, 9] {
623            let output = temp.path().join(format!("output_{level}.tar.gz"));
624            let config = CreationConfig::default()
625                .with_exclude_patterns(vec![])
626                .with_compression_level(level)
627                .unwrap()
628                .with_format(Some(ArchiveType::TarGz));
629
630            let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
631            assert_eq!(report.files_added, 1);
632            assert!(output.exists());
633        }
634    }
635
636    #[test]
637    #[cfg(unix)]
638    fn test_create_tar_preserves_permissions() {
639        use std::os::unix::fs::PermissionsExt;
640
641        let temp = TempDir::new().unwrap();
642        let output = temp.path().join("output.tar");
643
644        let source_dir = TempDir::new().unwrap();
645        let file_path = source_dir.path().join("test.txt");
646        fs::write(&file_path, "content").unwrap();
647        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
648
649        let config = CreationConfig::default()
650            .with_exclude_patterns(vec![])
651            .with_preserve_permissions(true)
652            .with_format(Some(ArchiveType::Tar));
653
654        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
655        assert_eq!(report.files_added, 1);
656
657        let extract_dir = TempDir::new().unwrap();
658        let security_config = SecurityConfig::default();
659        extract_archive(&output, extract_dir.path(), &security_config).unwrap();
660
661        let extracted = extract_dir.path().join("test.txt");
662        let perms = fs::metadata(&extracted).unwrap().permissions();
663        assert_eq!(perms.mode() & 0o777, 0o755);
664    }
665
666    #[test]
667    fn test_create_tar_report_statistics() {
668        let temp = TempDir::new().unwrap();
669        let output = temp.path().join("output.tar");
670
671        let source_dir = TempDir::new().unwrap();
672        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
673        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
674        fs::create_dir(source_dir.path().join("subdir")).unwrap();
675        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
676
677        let config = CreationConfig::default()
678            .with_exclude_patterns(vec![])
679            .with_include_hidden(true)
680            .with_format(Some(ArchiveType::Tar));
681
682        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
683
684        assert_eq!(report.files_added, 3);
685        assert!(report.directories_added >= 1);
686        assert_eq!(report.files_skipped, 0);
687        assert!(!report.has_warnings());
688        assert!(report.duration.as_nanos() > 0);
689    }
690
691    #[test]
692    fn test_create_tar_roundtrip() {
693        let temp = TempDir::new().unwrap();
694        let output = temp.path().join("output.tar.gz");
695
696        let source_dir = TempDir::new().unwrap();
697        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
698        fs::create_dir(source_dir.path().join("subdir")).unwrap();
699        fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
700
701        let config = CreationConfig::default()
702            .with_exclude_patterns(vec![])
703            .with_include_hidden(true)
704            .with_format(Some(ArchiveType::TarGz));
705
706        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
707        assert!(report.files_added >= 2);
708
709        let extract_dir = TempDir::new().unwrap();
710        let security_config = SecurityConfig::default();
711        extract_archive(&output, extract_dir.path(), &security_config).unwrap();
712
713        let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
714        assert_eq!(extracted1, "content1");
715
716        let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
717        assert_eq!(extracted2, "content2");
718    }
719
720    #[test]
721    fn test_create_tar_source_not_found() {
722        let temp = TempDir::new().unwrap();
723        let output = temp.path().join("output.tar");
724
725        let config = CreationConfig::default().with_format(Some(ArchiveType::Tar));
726        let result = create_archive(&output, &[Path::new("/nonexistent/path")], &config);
727
728        assert!(result.is_err());
729        assert!(matches!(
730            result.unwrap_err(),
731            ArchiveError::SourceNotFound { .. }
732        ));
733    }
734
735    #[test]
736    fn test_compression_level_to_flate2() {
737        // Default
738        let level = compression_level_to_flate2(None);
739        assert_eq!(level, flate2::Compression::default());
740
741        // Fast
742        let level = compression_level_to_flate2(Some(1));
743        assert_eq!(level, flate2::Compression::fast());
744
745        // Best
746        let level = compression_level_to_flate2(Some(9));
747        assert_eq!(level, flate2::Compression::best());
748
749        // Specific level
750        let level = compression_level_to_flate2(Some(5));
751        assert_eq!(level, flate2::Compression::new(5));
752    }
753
754    #[test]
755    fn test_compression_level_to_zstd() {
756        assert_eq!(compression_level_to_zstd(None), 3);
757        assert_eq!(compression_level_to_zstd(Some(1)), 1);
758        assert_eq!(compression_level_to_zstd(Some(6)), 3);
759        assert_eq!(compression_level_to_zstd(Some(7)), 10);
760        assert_eq!(compression_level_to_zstd(Some(9)), 19);
761    }
762
763    // NOTE: Progress tracking reader tests are now in creation/progress.rs
764
765    /// Writer that fails with an I/O error after `fail_after` bytes have been
766    /// written.
767    struct FailWriter {
768        written: usize,
769        fail_after: usize,
770    }
771
772    impl FailWriter {
773        fn new(fail_after: usize) -> Self {
774            Self {
775                written: 0,
776                fail_after,
777            }
778        }
779    }
780
781    impl Write for FailWriter {
782        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
783            if self.written >= self.fail_after {
784                return Err(std::io::Error::new(
785                    std::io::ErrorKind::WriteZero,
786                    "simulated write failure",
787                ));
788            }
789            let allowed = (self.fail_after - self.written).min(buf.len());
790            self.written += allowed;
791            Ok(allowed)
792        }
793
794        fn flush(&mut self) -> std::io::Result<()> {
795            Ok(())
796        }
797    }
798
799    /// Regression test for #226: verifies that errors from
800    /// `zstd::Encoder::finish()` are propagated rather than silently
801    /// swallowed via `Drop`.
802    ///
803    /// Uses a `FailWriter` that errors after a small number of bytes so that
804    /// the zstd encoder's `finish()` call encounters an I/O failure.
805    #[test]
806    fn test_zstd_encoder_finish_error_propagated() {
807        let source_dir = TempDir::new().unwrap();
808        fs::write(source_dir.path().join("a.txt"), "hello").unwrap();
809
810        let config = CreationConfig::default()
811            .with_exclude_patterns(vec![])
812            .with_format(Some(ArchiveType::TarZst));
813
814        // Allow enough bytes for the zstd header but fail mid-stream so that
815        // encoder.finish() must flush remaining data and hits the limit.
816        let fail_writer = FailWriter::new(8);
817        let level = compression_level_to_zstd(config.compression_level);
818        let mut encoder = zstd::Encoder::new(fail_writer, level).unwrap();
819        encoder.include_checksum(true).unwrap();
820
821        let mut noop = crate::NoopProgress;
822        let result =
823            create_tar_internal_with_progress(encoder, &[source_dir.path()], &config, &mut noop);
824
825        // Either the internal write or encoder.finish() must surface an error.
826        // We call finish() only if internal succeeded, mirroring the real code path.
827        let is_err = match result {
828            Err(_) => true,
829            Ok((_, enc)) => enc.finish().is_err(),
830        };
831        assert!(
832            is_err,
833            "expected an error from zstd encoder when underlying writer fails"
834        );
835    }
836
837    #[test]
838    fn test_create_tar_with_progress_callback() {
839        #[derive(Debug, Default, Clone)]
840        struct TestProgress {
841            entries_started: Vec<String>,
842            entries_completed: Vec<String>,
843            bytes_written: u64,
844            completed: bool,
845        }
846
847        impl ProgressCallback for TestProgress {
848            fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
849                self.entries_started
850                    .push(path.to_string_lossy().to_string());
851            }
852
853            fn on_bytes_written(&mut self, bytes: u64) {
854                self.bytes_written += bytes;
855            }
856
857            fn on_entry_complete(&mut self, path: &Path) {
858                self.entries_completed
859                    .push(path.to_string_lossy().to_string());
860            }
861
862            fn on_complete(&mut self) {
863                self.completed = true;
864            }
865        }
866
867        let temp = TempDir::new().unwrap();
868        let output = temp.path().join("output.tar");
869
870        // Create source directory with multiple files
871        let source_dir = TempDir::new().unwrap();
872        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
873        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
874        fs::create_dir(source_dir.path().join("subdir")).unwrap();
875        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
876
877        let config = CreationConfig::default()
878            .with_exclude_patterns(vec![])
879            .with_include_hidden(true);
880
881        let mut progress = TestProgress::default();
882
883        let report =
884            create_tar_with_progress(&output, &[source_dir.path()], &config, &mut progress)
885                .unwrap();
886
887        // Verify report
888        assert_eq!(report.files_added, 3);
889        assert!(report.directories_added >= 1);
890
891        // Verify callbacks were invoked
892        assert!(
893            progress.entries_started.len() >= 3,
894            "Expected at least 3 entry starts, got {}",
895            progress.entries_started.len()
896        );
897        assert!(
898            progress.entries_completed.len() >= 3,
899            "Expected at least 3 entry completions, got {}",
900            progress.entries_completed.len()
901        );
902        assert!(
903            progress.bytes_written > 0,
904            "Expected bytes written > 0, got {}",
905            progress.bytes_written
906        );
907        assert!(progress.completed, "Expected on_complete to be called");
908
909        // Verify specific entries
910        let has_file1 = progress
911            .entries_started
912            .iter()
913            .any(|p| p.contains("file1.txt"));
914        let has_file2 = progress
915            .entries_started
916            .iter()
917            .any(|p| p.contains("file2.txt"));
918        let has_file3 = progress
919            .entries_started
920            .iter()
921            .any(|p| p.contains("file3.txt"));
922
923        assert!(has_file1, "Expected file1.txt in progress callbacks");
924        assert!(has_file2, "Expected file2.txt in progress callbacks");
925        assert!(has_file3, "Expected file3.txt in progress callbacks");
926    }
927
928    /// Regression test for #226: `create_tar_zst_with_progress` calls
929    /// `encoder.finish()` and returns any I/O error it produces.
930    ///
931    /// The public function signature takes a `Path`, not a generic writer, so
932    /// we verify the happy path here (`finish()` called, valid zstd output).
933    /// The error-propagation path of `finish()` is covered by the
934    /// internal-function test `test_zstd_encoder_finish_error_propagated`.
935    #[test]
936    fn test_create_tar_zst_with_progress_calls_finish() {
937        let temp = TempDir::new().unwrap();
938        let output = temp.path().join("output.tar.zst");
939
940        let source_dir = TempDir::new().unwrap();
941        fs::write(source_dir.path().join("test.txt"), "zstd progress finish").unwrap();
942
943        let config = CreationConfig::default().with_exclude_patterns(vec![]);
944        let mut noop = crate::NoopProgress;
945        let report =
946            create_tar_zst_with_progress(&output, &[source_dir.path()], &config, &mut noop)
947                .unwrap();
948
949        assert_eq!(report.files_added, 1);
950        assert!(output.exists());
951
952        // A properly finished zstd frame starts with the zstd magic number.
953        let data = fs::read(&output).unwrap();
954        assert!(data.len() >= 4);
955        assert_eq!(&data[0..4], &[0x28, 0xB5, 0x2F, 0xFD]);
956    }
957}