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::config::Validated;
9use crate::creation::compression::compression_level_to_bzip2;
10use crate::creation::compression::compression_level_to_flate2;
11use crate::creation::compression::compression_level_to_xz;
12use crate::creation::compression::compression_level_to_zstd;
13use crate::creation::config::CreationConfig;
14use crate::creation::progress::ProgressReader;
15use crate::creation::progress::ProgressTracker;
16use crate::creation::report::CreationReport;
17use crate::creation::walker::EntryType;
18use crate::creation::walker::collect_entries;
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().validate()?;
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<Validated>,
102    progress: &mut dyn ProgressCallback,
103) -> Result<CreationReport> {
104    let file = File::create(output.as_ref())?;
105    let (mut report, file) = create_tar_internal_with_progress(file, sources, config, progress)?;
106    drop(file);
107    report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
108    Ok(report)
109}
110
111/// Creates a gzip-compressed TAR archive with progress reporting.
112///
113/// Identical to [`create_tar_with_progress`] but applies gzip compression.
114/// See that function for detailed documentation on progress callbacks and
115/// usage.
116///
117/// # Errors
118///
119/// Returns an error if output file cannot be created, compression fails, or I/O
120/// operations fail.
121pub fn create_tar_gz_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
122    output: P,
123    sources: &[Q],
124    config: &CreationConfig<Validated>,
125    progress: &mut dyn ProgressCallback,
126) -> Result<CreationReport> {
127    let file = File::create(output.as_ref())?;
128    let level = compression_level_to_flate2(config.compression_level);
129    let encoder = flate2::write::GzEncoder::new(file, level);
130    let (mut report, encoder) =
131        create_tar_internal_with_progress(encoder, sources, config, progress)?;
132    encoder.finish()?;
133    report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
134    Ok(report)
135}
136
137/// Creates a bzip2-compressed TAR archive with progress reporting.
138///
139/// Identical to [`create_tar_with_progress`] but applies bzip2 compression.
140/// See that function for detailed documentation on progress callbacks and
141/// usage.
142///
143/// # Errors
144///
145/// Returns an error if output file cannot be created, compression fails, or I/O
146/// operations fail.
147pub fn create_tar_bz2_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
148    output: P,
149    sources: &[Q],
150    config: &CreationConfig<Validated>,
151    progress: &mut dyn ProgressCallback,
152) -> Result<CreationReport> {
153    let file = File::create(output.as_ref())?;
154    let level = compression_level_to_bzip2(config.compression_level);
155    let encoder = bzip2::write::BzEncoder::new(file, level);
156    let (mut report, encoder) =
157        create_tar_internal_with_progress(encoder, sources, config, progress)?;
158    encoder.finish()?;
159    report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
160    Ok(report)
161}
162
163/// Creates an xz-compressed TAR archive with progress reporting.
164///
165/// Identical to [`create_tar_with_progress`] but applies xz compression.
166/// See that function for detailed documentation on progress callbacks and
167/// usage.
168///
169/// # Errors
170///
171/// Returns an error if output file cannot be created, compression fails, or I/O
172/// operations fail.
173pub fn create_tar_xz_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
174    output: P,
175    sources: &[Q],
176    config: &CreationConfig<Validated>,
177    progress: &mut dyn ProgressCallback,
178) -> Result<CreationReport> {
179    let file = File::create(output.as_ref())?;
180    let level = compression_level_to_xz(config.compression_level);
181    let encoder = xz2::write::XzEncoder::new(file, level);
182    let (mut report, encoder) =
183        create_tar_internal_with_progress(encoder, sources, config, progress)?;
184    encoder.finish()?;
185    report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
186    Ok(report)
187}
188
189/// Creates a zstd-compressed TAR archive with progress reporting.
190///
191/// Identical to [`create_tar_with_progress`] but applies zstd compression.
192/// See that function for detailed documentation on progress callbacks and
193/// usage.
194///
195/// # Errors
196///
197/// Returns an error if output file cannot be created, compression fails, or I/O
198/// operations fail.
199pub fn create_tar_zst_with_progress<P: AsRef<Path>, Q: AsRef<Path>>(
200    output: P,
201    sources: &[Q],
202    config: &CreationConfig<Validated>,
203    progress: &mut dyn ProgressCallback,
204) -> Result<CreationReport> {
205    let file = File::create(output.as_ref())?;
206    let level = compression_level_to_zstd(config.compression_level);
207    let mut encoder = zstd::Encoder::new(file, level)?;
208    encoder.include_checksum(true)?;
209
210    let (mut report, encoder) =
211        create_tar_internal_with_progress(encoder, sources, config, progress)?;
212    encoder.finish()?;
213
214    report.bytes_compressed = std::fs::metadata(output.as_ref())?.len();
215    Ok(report)
216}
217
218/// Internal function that creates TAR with any writer and progress reporting.
219///
220/// Returns `(report, writer)` so callers that wrap the writer (e.g. zstd
221/// encoder) can finalize it after all TAR data has been flushed.
222fn create_tar_internal_with_progress<W: Write, P: AsRef<Path>>(
223    writer: W,
224    sources: &[P],
225    config: &CreationConfig<Validated>,
226    progress: &mut dyn ProgressCallback,
227) -> Result<(CreationReport, W)> {
228    let mut builder = Builder::new(writer);
229    let mut report = CreationReport::default();
230    let start = std::time::Instant::now();
231
232    // Single-pass collection of entries (avoids double directory traversal)
233    let entries = collect_entries(sources, config)?;
234    let total_entries = entries.len();
235
236    let mut tracker = ProgressTracker::new(progress, total_entries);
237
238    for entry in &entries {
239        match &entry.entry_type {
240            EntryType::File => {
241                tracker.on_entry_start(&entry.archive_path);
242                add_file_to_tar_with_progress_impl(
243                    &mut builder,
244                    &entry.path,
245                    &entry.archive_path,
246                    config,
247                    &mut report,
248                    tracker.callback(),
249                )?;
250                tracker.on_entry_complete(&entry.archive_path);
251            }
252            EntryType::Directory => {
253                tracker.on_entry_start(&entry.archive_path);
254                add_directory_to_tar(
255                    &mut builder,
256                    &entry.path,
257                    &entry.archive_path,
258                    config,
259                    &mut report,
260                )?;
261                tracker.on_entry_complete(&entry.archive_path);
262            }
263            EntryType::Symlink { target } => {
264                tracker.on_entry_start(&entry.archive_path);
265                if config.follow_symlinks {
266                    add_file_to_tar_with_progress_impl(
267                        &mut builder,
268                        &entry.path,
269                        &entry.archive_path,
270                        config,
271                        &mut report,
272                        tracker.callback(),
273                    )?;
274                } else {
275                    add_symlink_to_tar(&mut builder, &entry.archive_path, target, &mut report)?;
276                }
277                tracker.on_entry_complete(&entry.archive_path);
278            }
279        }
280    }
281
282    // Finish writing TAR
283    builder.finish()?;
284
285    let mut writer = builder.into_inner()?;
286    writer.flush()?;
287
288    report.duration = start.elapsed();
289
290    tracker.on_complete();
291
292    Ok((report, writer))
293}
294
295/// Adds a directory entry to the TAR archive.
296///
297/// Skips the archive root (empty relative `archive_path`), mirroring the ZIP
298/// handler's root-skip behavior, since the root itself is not a meaningful
299/// entry in the archive.
300fn add_directory_to_tar<W: Write>(
301    builder: &mut Builder<W>,
302    dir_path: &Path,
303    archive_path: &Path,
304    config: &CreationConfig<Validated>,
305    report: &mut CreationReport,
306) -> Result<()> {
307    if archive_path.as_os_str().is_empty() {
308        return Ok(());
309    }
310
311    let mut header = Header::new_gnu();
312    header.set_entry_type(tar::EntryType::Directory);
313    header.set_size(0);
314
315    if config.preserve_permissions {
316        let metadata = std::fs::metadata(dir_path)?;
317        set_permissions(&mut header, &metadata);
318    } else {
319        // Deterministic, traversable default (mirrors tar-rs's
320        // `HeaderMode::Deterministic` and the `zip` crate's directory
321        // default) rather than the all-zero mode `Header::new_gnu()`
322        // otherwise leaves behind, which produces directories the owner
323        // cannot even traverse without a manual `chmod -R`.
324        header.set_mode(0o755);
325        header.set_uid(0);
326        header.set_gid(0);
327        header.set_mtime(0);
328    }
329
330    header.set_cksum();
331    builder.append_data(&mut header, archive_path, std::io::empty())?;
332
333    report.directories_added += 1;
334
335    Ok(())
336}
337
338/// Adds a single file to the TAR archive with progress reporting.
339fn add_file_to_tar_with_progress_impl<W: Write>(
340    builder: &mut Builder<W>,
341    file_path: &Path,
342    archive_path: &Path,
343    config: &CreationConfig<Validated>,
344    report: &mut CreationReport,
345    progress: &mut dyn ProgressCallback,
346) -> Result<()> {
347    let file = File::open(file_path)?;
348    let metadata = file.metadata()?;
349    let size = metadata.len();
350
351    let mut header = Header::new_gnu();
352    header.set_size(size);
353    header.set_cksum();
354
355    if config.preserve_permissions {
356        set_permissions(&mut header, &metadata);
357    }
358
359    let mut tracked_file = ProgressReader::new(file, progress);
360    builder.append_data(&mut header, archive_path, &mut tracked_file)?;
361
362    report.files_added += 1;
363    report.bytes_written += size;
364
365    Ok(())
366}
367
368/// Adds a symlink to the TAR archive.
369#[cfg(unix)]
370fn add_symlink_to_tar<W: Write>(
371    builder: &mut Builder<W>,
372    link_path: &Path,
373    target: &Path,
374    report: &mut CreationReport,
375) -> Result<()> {
376    let mut header = Header::new_gnu();
377    header.set_entry_type(tar::EntryType::Symlink);
378    header.set_size(0);
379    header.set_cksum();
380
381    builder.append_link(&mut header, link_path, target)?;
382
383    report.symlinks_added += 1;
384
385    Ok(())
386}
387
388#[cfg(not(unix))]
389fn add_symlink_to_tar<W: Write>(
390    _builder: &mut Builder<W>,
391    _link_path: &Path,
392    _target: &Path,
393    report: &mut CreationReport,
394) -> Result<()> {
395    // On non-Unix platforms, skip symlinks
396    report.files_skipped = report.files_skipped.saturating_add(1);
397    report.add_warning("Symlinks not supported on this platform");
398    Ok(())
399}
400
401/// Sets file permissions in TAR header from metadata.
402#[cfg(unix)]
403fn set_permissions(header: &mut Header, metadata: &std::fs::Metadata) {
404    use std::os::unix::fs::MetadataExt;
405    let mode = metadata.mode();
406    header.set_mode(mode);
407    header.set_uid(u64::from(metadata.uid()));
408    header.set_gid(u64::from(metadata.gid()));
409    // mtime can be negative for dates before epoch, clamp to 0
410    #[allow(clippy::cast_sign_loss)] // Intentional: clamped to non-negative
411    let mtime = metadata.mtime().max(0) as u64;
412    header.set_mtime(mtime);
413}
414
415#[cfg(not(unix))]
416fn set_permissions(header: &mut Header, metadata: &std::fs::Metadata) {
417    // On non-Unix platforms, set basic permissions
418    let mode = if metadata.permissions().readonly() {
419        0o444
420    } else {
421        0o644
422    };
423    header.set_mode(mode);
424
425    // Set modification time
426    if let Ok(modified) = metadata.modified() {
427        if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
428            header.set_mtime(duration.as_secs());
429        }
430    }
431}
432
433/// Format creator for uncompressed TAR archives.
434pub struct TarCreator;
435
436/// Format creator for gzip-compressed TAR archives.
437pub struct TarGzCreator;
438
439/// Format creator for bzip2-compressed TAR archives.
440pub struct TarBz2Creator;
441
442/// Format creator for xz-compressed TAR archives.
443pub struct TarXzCreator;
444
445/// Format creator for zstd-compressed TAR archives.
446pub struct TarZstCreator;
447
448impl crate::formats::traits::FormatCreator for TarCreator {
449    fn create(
450        &self,
451        output: &Path,
452        sources: &[&Path],
453        config: &CreationConfig<Validated>,
454        progress: &mut dyn ProgressCallback,
455    ) -> crate::Result<crate::creation::CreationReport> {
456        create_tar_with_progress(output, sources, config, progress)
457    }
458
459    fn format_name(&self) -> &'static str {
460        "tar"
461    }
462}
463
464impl crate::formats::traits::FormatCreator for TarGzCreator {
465    fn create(
466        &self,
467        output: &Path,
468        sources: &[&Path],
469        config: &CreationConfig<Validated>,
470        progress: &mut dyn ProgressCallback,
471    ) -> crate::Result<crate::creation::CreationReport> {
472        create_tar_gz_with_progress(output, sources, config, progress)
473    }
474
475    fn format_name(&self) -> &'static str {
476        "tar.gz"
477    }
478}
479
480impl crate::formats::traits::FormatCreator for TarBz2Creator {
481    fn create(
482        &self,
483        output: &Path,
484        sources: &[&Path],
485        config: &CreationConfig<Validated>,
486        progress: &mut dyn ProgressCallback,
487    ) -> crate::Result<crate::creation::CreationReport> {
488        create_tar_bz2_with_progress(output, sources, config, progress)
489    }
490
491    fn format_name(&self) -> &'static str {
492        "tar.bz2"
493    }
494}
495
496impl crate::formats::traits::FormatCreator for TarXzCreator {
497    fn create(
498        &self,
499        output: &Path,
500        sources: &[&Path],
501        config: &CreationConfig<Validated>,
502        progress: &mut dyn ProgressCallback,
503    ) -> crate::Result<crate::creation::CreationReport> {
504        create_tar_xz_with_progress(output, sources, config, progress)
505    }
506
507    fn format_name(&self) -> &'static str {
508        "tar.xz"
509    }
510}
511
512impl crate::formats::traits::FormatCreator for TarZstCreator {
513    fn create(
514        &self,
515        output: &Path,
516        sources: &[&Path],
517        config: &CreationConfig<Validated>,
518        progress: &mut dyn ProgressCallback,
519    ) -> crate::Result<crate::creation::CreationReport> {
520        create_tar_zst_with_progress(output, sources, config, progress)
521    }
522
523    fn format_name(&self) -> &'static str {
524        "tar.zst"
525    }
526}
527
528#[cfg(test)]
529#[allow(clippy::unwrap_used)] // Allow unwrap in tests for brevity
530mod tests {
531    use super::*;
532    use crate::ArchiveError;
533    use crate::SecurityConfig;
534    use crate::api::create_archive;
535    use crate::api::extract_archive;
536    use crate::formats::detect::ArchiveType;
537    use std::assert_matches;
538    use std::fs;
539    use tempfile::TempDir;
540
541    #[test]
542    fn test_create_tar_single_file() {
543        let temp = TempDir::new().unwrap();
544        let output = temp.path().join("output.tar");
545
546        let source_dir = TempDir::new().unwrap();
547        fs::write(source_dir.path().join("test.txt"), "Hello TAR").unwrap();
548
549        let config = CreationConfig::default()
550            .with_exclude_patterns(vec![])
551            .with_include_hidden(true)
552            .with_format(Some(ArchiveType::Tar));
553
554        let report = create_archive(
555            &output,
556            &[source_dir.path().join("test.txt").as_path()],
557            &config,
558        )
559        .unwrap();
560
561        assert_eq!(report.files_added, 1);
562        assert!(report.bytes_written > 0);
563        assert!(output.exists());
564    }
565
566    #[test]
567    fn test_create_tar_directory() {
568        let temp = TempDir::new().unwrap();
569        let output = temp.path().join("output.tar");
570
571        let source_dir = TempDir::new().unwrap();
572        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
573        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
574        fs::create_dir(source_dir.path().join("subdir")).unwrap();
575        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
576
577        let config = CreationConfig::default()
578            .with_exclude_patterns(vec![])
579            .with_include_hidden(true)
580            .with_format(Some(ArchiveType::Tar));
581
582        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
583
584        assert_eq!(report.files_added, 3);
585        // Only "subdir" is counted; the archive root itself is skipped
586        // (matches ZIP's root-skip behavior, see #400).
587        assert_eq!(report.directories_added, 1);
588        assert!(output.exists());
589    }
590
591    #[test]
592    fn test_create_tar_gz_compression() {
593        let temp = TempDir::new().unwrap();
594        let output = temp.path().join("output.tar.gz");
595
596        let source_dir = TempDir::new().unwrap();
597        fs::write(source_dir.path().join("test.txt"), "a".repeat(1000)).unwrap();
598
599        let config = CreationConfig::default()
600            .with_exclude_patterns(vec![])
601            .with_compression_level(9)
602            .unwrap()
603            .with_format(Some(ArchiveType::TarGz));
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_eq!(&data[0..2], &[0x1f, 0x8b]); // gzip magic bytes
612    }
613
614    #[test]
615    fn test_create_tar_bz2_compression() {
616        let temp = TempDir::new().unwrap();
617        let output = temp.path().join("output.tar.bz2");
618
619        let source_dir = TempDir::new().unwrap();
620        fs::write(source_dir.path().join("test.txt"), "bzip2 test").unwrap();
621
622        let config = CreationConfig::default()
623            .with_exclude_patterns(vec![])
624            .with_format(Some(ArchiveType::TarBz2));
625
626        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
627
628        assert_eq!(report.files_added, 1);
629        assert!(output.exists());
630
631        let data = fs::read(&output).unwrap();
632        assert_eq!(&data[0..3], b"BZh"); // bzip2 magic bytes
633    }
634
635    #[test]
636    fn test_create_tar_xz_compression() {
637        let temp = TempDir::new().unwrap();
638        let output = temp.path().join("output.tar.xz");
639
640        let source_dir = TempDir::new().unwrap();
641        fs::write(source_dir.path().join("test.txt"), "xz test").unwrap();
642
643        let config = CreationConfig::default()
644            .with_exclude_patterns(vec![])
645            .with_format(Some(ArchiveType::TarXz));
646
647        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
648
649        assert_eq!(report.files_added, 1);
650        assert!(output.exists());
651
652        let data = fs::read(&output).unwrap();
653        assert_eq!(&data[0..6], &[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]); // xz magic bytes
654    }
655
656    #[test]
657    fn test_create_tar_zst_compression() {
658        let temp = TempDir::new().unwrap();
659        let output = temp.path().join("output.tar.zst");
660
661        let source_dir = TempDir::new().unwrap();
662        fs::write(source_dir.path().join("test.txt"), "zstd test").unwrap();
663
664        let config = CreationConfig::default()
665            .with_exclude_patterns(vec![])
666            .with_format(Some(ArchiveType::TarZst));
667
668        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
669
670        assert_eq!(report.files_added, 1);
671        assert!(output.exists());
672
673        let data = fs::read(&output).unwrap();
674        assert!(data.len() >= 4, "output file should have data");
675        assert_eq!(&data[0..4], &[0x28, 0xB5, 0x2F, 0xFD]); // zstd magic bytes
676    }
677
678    /// Regression test for #443: sweeps every tar compression backend
679    /// (flate2, bzip2, xz2, zstd) across the full 1-9 level range through
680    /// the validated `create_archive` path. This is the test that would
681    /// have caught the flate2 1.1.9 backend swap that panicked on
682    /// out-of-range levels — a level-1-and-9-only sweep on tar.gz alone
683    /// does not exercise `xz2::Stream::new_easy_encoder`, one of the two
684    /// confirmed panic sites, at any level but the default.
685    #[test]
686    fn test_create_tar_compression_levels() {
687        let temp = TempDir::new().unwrap();
688
689        let source_dir = TempDir::new().unwrap();
690        fs::write(source_dir.path().join("test.txt"), "a".repeat(10000)).unwrap();
691
692        for format in [
693            ArchiveType::TarGz,
694            ArchiveType::TarBz2,
695            ArchiveType::TarXz,
696            ArchiveType::TarZst,
697        ] {
698            for level in 1..=9 {
699                let output = temp.path().join(format!("output_{format:?}_{level}.tar"));
700                let config = CreationConfig::default()
701                    .with_exclude_patterns(vec![])
702                    .with_compression_level(level)
703                    .unwrap()
704                    .with_format(Some(format));
705
706                let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
707                assert_eq!(report.files_added, 1, "{format:?} level {level}");
708                assert!(output.exists(), "{format:?} level {level}");
709            }
710        }
711    }
712
713    #[test]
714    #[cfg(unix)]
715    fn test_create_tar_preserves_permissions() {
716        use std::os::unix::fs::PermissionsExt;
717
718        let temp = TempDir::new().unwrap();
719        let output = temp.path().join("output.tar");
720
721        let source_dir = TempDir::new().unwrap();
722        let file_path = source_dir.path().join("test.txt");
723        fs::write(&file_path, "content").unwrap();
724        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755)).unwrap();
725
726        let config = CreationConfig::default()
727            .with_exclude_patterns(vec![])
728            .with_preserve_permissions(true)
729            .with_format(Some(ArchiveType::Tar));
730
731        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
732        assert_eq!(report.files_added, 1);
733
734        let extract_dir = TempDir::new().unwrap();
735        let security_config = SecurityConfig::default();
736        extract_archive(&output, extract_dir.path(), &security_config).unwrap();
737
738        let extracted = extract_dir.path().join("test.txt");
739        let perms = fs::metadata(&extracted).unwrap().permissions();
740        assert_eq!(perms.mode() & 0o777, 0o755);
741    }
742
743    /// Regression test for impl-critic finding S1: with
744    /// `preserve_permissions: false`, TAR directory entries used to inherit
745    /// `Header::new_gnu()`'s zero-filled mode, producing directories mode
746    /// `0o000` that the owner could not even traverse after extraction.
747    #[test]
748    #[cfg(unix)]
749    fn test_create_tar_directory_mode_without_preserve_permissions() {
750        use std::os::unix::fs::PermissionsExt;
751
752        let temp = TempDir::new().unwrap();
753        let output = temp.path().join("output.tar");
754
755        let source_dir = TempDir::new().unwrap();
756        let subdir = source_dir.path().join("subdir");
757        fs::create_dir(&subdir).unwrap();
758        // Give the source directory a mode that must NOT leak through, to
759        // prove the archive uses the deterministic default rather than the
760        // real (unpreserved) source mode.
761        fs::set_permissions(&subdir, fs::Permissions::from_mode(0o700)).unwrap();
762
763        let config = CreationConfig::default()
764            .with_exclude_patterns(vec![])
765            .with_preserve_permissions(false)
766            .with_format(Some(ArchiveType::Tar));
767
768        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
769        assert_eq!(report.directories_added, 1);
770
771        let extract_dir = TempDir::new().unwrap();
772        let security_config = SecurityConfig::default();
773        extract_archive(&output, extract_dir.path(), &security_config).unwrap();
774
775        let extracted = extract_dir.path().join("subdir");
776        let mode = fs::metadata(&extracted).unwrap().permissions().mode() & 0o777;
777        assert_eq!(
778            mode, 0o755,
779            "directory entries without preserve_permissions must use a traversable \
780             deterministic default, not mode 0"
781        );
782    }
783
784    #[test]
785    fn test_create_tar_report_statistics() {
786        let temp = TempDir::new().unwrap();
787        let output = temp.path().join("output.tar");
788
789        let source_dir = TempDir::new().unwrap();
790        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
791        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
792        fs::create_dir(source_dir.path().join("subdir")).unwrap();
793        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
794
795        let config = CreationConfig::default()
796            .with_exclude_patterns(vec![])
797            .with_include_hidden(true)
798            .with_format(Some(ArchiveType::Tar));
799
800        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
801
802        assert_eq!(report.files_added, 3);
803        assert!(report.directories_added >= 1);
804        assert_eq!(report.files_skipped, 0);
805        assert!(!report.has_warnings());
806        assert!(report.duration.as_nanos() > 0);
807    }
808
809    #[test]
810    fn test_create_tar_roundtrip() {
811        let temp = TempDir::new().unwrap();
812        let output = temp.path().join("output.tar.gz");
813
814        let source_dir = TempDir::new().unwrap();
815        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
816        fs::create_dir(source_dir.path().join("subdir")).unwrap();
817        fs::write(source_dir.path().join("subdir/file2.txt"), "content2").unwrap();
818
819        let config = CreationConfig::default()
820            .with_exclude_patterns(vec![])
821            .with_include_hidden(true)
822            .with_format(Some(ArchiveType::TarGz));
823
824        let report = create_archive(&output, &[source_dir.path()], &config).unwrap();
825        assert!(report.files_added >= 2);
826
827        let extract_dir = TempDir::new().unwrap();
828        let security_config = SecurityConfig::default();
829        extract_archive(&output, extract_dir.path(), &security_config).unwrap();
830
831        let extracted1 = fs::read_to_string(extract_dir.path().join("file1.txt")).unwrap();
832        assert_eq!(extracted1, "content1");
833
834        let extracted2 = fs::read_to_string(extract_dir.path().join("subdir/file2.txt")).unwrap();
835        assert_eq!(extracted2, "content2");
836    }
837
838    #[test]
839    fn test_create_tar_source_not_found() {
840        let temp = TempDir::new().unwrap();
841        let output = temp.path().join("output.tar");
842
843        let config = CreationConfig::default().with_format(Some(ArchiveType::Tar));
844        let result = create_archive(&output, &[Path::new("/nonexistent/path")], &config);
845
846        assert!(result.is_err());
847        assert_matches!(result.unwrap_err(), ArchiveError::SourceNotFound { .. });
848    }
849
850    #[test]
851    fn test_compression_level_to_flate2() {
852        // Default
853        let level = compression_level_to_flate2(None);
854        assert_eq!(level, flate2::Compression::default());
855
856        // Fast
857        let level = compression_level_to_flate2(Some(1));
858        assert_eq!(level, flate2::Compression::fast());
859
860        // Best
861        let level = compression_level_to_flate2(Some(9));
862        assert_eq!(level, flate2::Compression::best());
863
864        // Specific level
865        let level = compression_level_to_flate2(Some(5));
866        assert_eq!(level, flate2::Compression::new(5));
867    }
868
869    #[test]
870    fn test_compression_level_to_zstd() {
871        assert_eq!(compression_level_to_zstd(None), 3);
872        assert_eq!(compression_level_to_zstd(Some(1)), 1);
873        assert_eq!(compression_level_to_zstd(Some(6)), 3);
874        assert_eq!(compression_level_to_zstd(Some(7)), 10);
875        assert_eq!(compression_level_to_zstd(Some(9)), 19);
876    }
877
878    // NOTE: Progress tracking reader tests are now in creation/progress.rs
879
880    /// Writer that fails with an I/O error after `fail_after` bytes have been
881    /// written.
882    struct FailWriter {
883        written: usize,
884        fail_after: usize,
885    }
886
887    impl FailWriter {
888        fn new(fail_after: usize) -> Self {
889            Self {
890                written: 0,
891                fail_after,
892            }
893        }
894    }
895
896    impl Write for FailWriter {
897        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
898            if self.written >= self.fail_after {
899                return Err(std::io::Error::new(
900                    std::io::ErrorKind::WriteZero,
901                    "simulated write failure",
902                ));
903            }
904            let allowed = (self.fail_after - self.written).min(buf.len());
905            self.written += allowed;
906            Ok(allowed)
907        }
908
909        fn flush(&mut self) -> std::io::Result<()> {
910            Ok(())
911        }
912    }
913
914    /// Regression test for #226: verifies that errors from
915    /// `zstd::Encoder::finish()` are propagated rather than silently
916    /// swallowed via `Drop`.
917    ///
918    /// Uses a `FailWriter` that errors after a small number of bytes so that
919    /// the zstd encoder's `finish()` call encounters an I/O failure.
920    #[test]
921    fn test_zstd_encoder_finish_error_propagated() {
922        let source_dir = TempDir::new().unwrap();
923        fs::write(source_dir.path().join("a.txt"), "hello").unwrap();
924
925        let config = CreationConfig::default()
926            .with_exclude_patterns(vec![])
927            .with_format(Some(ArchiveType::TarZst))
928            .validate()
929            .unwrap();
930
931        // Allow enough bytes for the zstd header but fail mid-stream so that
932        // encoder.finish() must flush remaining data and hits the limit.
933        let fail_writer = FailWriter::new(8);
934        let level = compression_level_to_zstd(config.compression_level);
935        let mut encoder = zstd::Encoder::new(fail_writer, level).unwrap();
936        encoder.include_checksum(true).unwrap();
937
938        let mut noop = crate::NoopProgress;
939        let result =
940            create_tar_internal_with_progress(encoder, &[source_dir.path()], &config, &mut noop);
941
942        // Either the internal write or encoder.finish() must surface an error.
943        // We call finish() only if internal succeeded, mirroring the real code path.
944        let is_err = match result {
945            Err(_) => true,
946            Ok((_, enc)) => enc.finish().is_err(),
947        };
948        assert!(
949            is_err,
950            "expected an error from zstd encoder when underlying writer fails"
951        );
952    }
953
954    #[test]
955    fn test_create_tar_with_progress_callback() {
956        #[derive(Debug, Default, Clone)]
957        struct TestProgress {
958            entries_started: Vec<String>,
959            entries_completed: Vec<String>,
960            bytes_written: u64,
961            completed: bool,
962        }
963
964        impl ProgressCallback for TestProgress {
965            fn on_entry_start(&mut self, path: &Path, _total: usize, _current: usize) {
966                self.entries_started
967                    .push(path.to_string_lossy().to_string());
968            }
969
970            fn on_bytes_written(&mut self, bytes: u64) {
971                self.bytes_written += bytes;
972            }
973
974            fn on_entry_complete(&mut self, path: &Path) {
975                self.entries_completed
976                    .push(path.to_string_lossy().to_string());
977            }
978
979            fn on_complete(&mut self) {
980                self.completed = true;
981            }
982        }
983
984        let temp = TempDir::new().unwrap();
985        let output = temp.path().join("output.tar");
986
987        // Create source directory with multiple files
988        let source_dir = TempDir::new().unwrap();
989        fs::write(source_dir.path().join("file1.txt"), "content1").unwrap();
990        fs::write(source_dir.path().join("file2.txt"), "content2").unwrap();
991        fs::create_dir(source_dir.path().join("subdir")).unwrap();
992        fs::write(source_dir.path().join("subdir/file3.txt"), "content3").unwrap();
993
994        let config = CreationConfig::default()
995            .with_exclude_patterns(vec![])
996            .with_include_hidden(true)
997            .validate()
998            .unwrap();
999
1000        let mut progress = TestProgress::default();
1001
1002        let report =
1003            create_tar_with_progress(&output, &[source_dir.path()], &config, &mut progress)
1004                .unwrap();
1005
1006        // Verify report
1007        assert_eq!(report.files_added, 3);
1008        assert!(report.directories_added >= 1);
1009
1010        // Verify callbacks were invoked
1011        assert!(
1012            progress.entries_started.len() >= 3,
1013            "Expected at least 3 entry starts, got {}",
1014            progress.entries_started.len()
1015        );
1016        assert!(
1017            progress.entries_completed.len() >= 3,
1018            "Expected at least 3 entry completions, got {}",
1019            progress.entries_completed.len()
1020        );
1021        assert!(
1022            progress.bytes_written > 0,
1023            "Expected bytes written > 0, got {}",
1024            progress.bytes_written
1025        );
1026        assert!(progress.completed, "Expected on_complete to be called");
1027
1028        // Verify specific entries
1029        let has_file1 = progress
1030            .entries_started
1031            .iter()
1032            .any(|p| p.contains("file1.txt"));
1033        let has_file2 = progress
1034            .entries_started
1035            .iter()
1036            .any(|p| p.contains("file2.txt"));
1037        let has_file3 = progress
1038            .entries_started
1039            .iter()
1040            .any(|p| p.contains("file3.txt"));
1041
1042        assert!(has_file1, "Expected file1.txt in progress callbacks");
1043        assert!(has_file2, "Expected file2.txt in progress callbacks");
1044        assert!(has_file3, "Expected file3.txt in progress callbacks");
1045    }
1046
1047    /// Regression test for #226: `create_tar_zst_with_progress` calls
1048    /// `encoder.finish()` and returns any I/O error it produces.
1049    ///
1050    /// The public function signature takes a `Path`, not a generic writer, so
1051    /// we verify the happy path here (`finish()` called, valid zstd output).
1052    /// The error-propagation path of `finish()` is covered by the
1053    /// internal-function test `test_zstd_encoder_finish_error_propagated`.
1054    #[test]
1055    fn test_create_tar_zst_with_progress_calls_finish() {
1056        let temp = TempDir::new().unwrap();
1057        let output = temp.path().join("output.tar.zst");
1058
1059        let source_dir = TempDir::new().unwrap();
1060        fs::write(source_dir.path().join("test.txt"), "zstd progress finish").unwrap();
1061
1062        let config = CreationConfig::default()
1063            .with_exclude_patterns(vec![])
1064            .validate()
1065            .unwrap();
1066        let mut noop = crate::NoopProgress;
1067        let report =
1068            create_tar_zst_with_progress(&output, &[source_dir.path()], &config, &mut noop)
1069                .unwrap();
1070
1071        assert_eq!(report.files_added, 1);
1072        assert!(output.exists());
1073
1074        // A properly finished zstd frame starts with the zstd magic number.
1075        let data = fs::read(&output).unwrap();
1076        assert!(data.len() >= 4);
1077        assert_eq!(&data[0..4], &[0x28, 0xB5, 0x2F, 0xFD]);
1078    }
1079}