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