From 09fb9121e2a0894e233fd40e96c02ba22e72abce Mon Sep 17 00:00:00 2001 From: Kevin Burke Date: Sat, 4 Apr 2026 09:48:15 -0700 Subject: [PATCH] tar: add explicit zstd compression support Add an explicit --zstd flag for create, list, and extract so .tar.zst archives work end to end. Keep the scope narrow by requiring the flag instead of trying to autodetect compression. Add CLI coverage and end-to-end tests for creating, listing, and extracting zstd-compressed archives. --- Cargo.lock | 3 + Cargo.toml | 2 + src/uu/tar/Cargo.toml | 4 + src/uu/tar/src/errors.rs | 8 ++ src/uu/tar/src/operations/compression.rs | 154 ++++++++++++++++++++++ src/uu/tar/src/operations/create.rs | 73 +++++++++-- src/uu/tar/src/operations/extract.rs | 55 +++++++- src/uu/tar/src/operations/list.rs | 58 ++++++++- src/uu/tar/src/operations/mod.rs | 38 ++++++ src/uu/tar/src/tar.rs | 63 ++++++++- src/uu/tar/tests/test_cli.rs | 11 ++ tests/by-util/test_tar.rs | 159 +++++++++++++++++++++++ 12 files changed, 602 insertions(+), 26 deletions(-) create mode 100644 src/uu/tar/src/operations/compression.rs diff --git a/Cargo.lock b/Cargo.lock index 1633f43..69e7d0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,6 +1318,7 @@ dependencies = [ "uutests", "xattr", "zip", + "zstd", ] [[package]] @@ -1497,8 +1498,10 @@ dependencies = [ "clap", "regex", "tar", + "tempfile", "thiserror", "uucore", + "zstd", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a36b0e2..4332fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ uucore = { git = "https://github.com/uutils/coreutils" } uutests = { git = "https://github.com/uutils/coreutils" } xattr = "1.3.1" zip = "8.0" +zstd = "0.13.3" [dependencies] clap = { workspace = true } @@ -76,6 +77,7 @@ tar-rs-crate = { version = "0.4", package = "tar" } tempfile = { workspace = true } uucore = { workspace = true, features = ["entries", "process", "signals"] } uutests = { workspace = true } +zstd = { workspace = true } [target.'cfg(unix)'.dev-dependencies] xattr = { workspace = true } diff --git a/src/uu/tar/Cargo.toml b/src/uu/tar/Cargo.toml index 3b341c7..8f703aa 100644 --- a/src/uu/tar/Cargo.toml +++ b/src/uu/tar/Cargo.toml @@ -19,6 +19,7 @@ regex = { workspace = true } tar = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } +zstd = { workspace = true } [lib] path = "src/tar.rs" @@ -26,3 +27,6 @@ path = "src/tar.rs" [[bin]] name = "tar" path = "src/main.rs" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/uu/tar/src/errors.rs b/src/uu/tar/src/errors.rs index 5630a95..19b42f7 100644 --- a/src/uu/tar/src/errors.rs +++ b/src/uu/tar/src/errors.rs @@ -27,6 +27,10 @@ pub enum TarError { #[error("tar: Cannot read entry path: {0}")] CannotReadEntryPath(io::Error), + /// Invalid archive format or corrupted archive + #[error("tar: {0}")] + InvalidArchive(String), + /// File or directory not found #[error("tar: {path}: Cannot open: No such file or directory")] FileNotFound { path: PathBuf }, @@ -51,6 +55,10 @@ pub enum TarError { #[error("tar: Cannot extract '{path}': {source}")] CannotExtract { path: PathBuf, source: io::Error }, + /// General tar operation error + #[error("tar: {0}")] + TarOperationError(String), + /// Cannot finalize the archive #[error("tar: Cannot finalize archive: {0}")] CannotFinalizeArchive(io::Error), diff --git a/src/uu/tar/src/operations/compression.rs b/src/uu/tar/src/operations/compression.rs new file mode 100644 index 0000000..3a94103 --- /dev/null +++ b/src/uu/tar/src/operations/compression.rs @@ -0,0 +1,154 @@ +// This file is part of the uutils tar package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use crate::errors::TarError; +use crate::CompressionMode; +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; + +pub fn open_archive_reader( + archive_path: &Path, + compression: CompressionMode, +) -> Result, TarError> { + let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; + + match compression { + CompressionMode::None => Ok(Box::new(file)), + CompressionMode::Zstd => { + let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| { + TarError::InvalidArchive(format!( + "Failed to initialize zstd decoder for '{}': {}", + archive_path.display(), + e + )) + })?; + Ok(Box::new(decoder)) + } + } +} + +pub struct ArchiveWriter { + inner: ArchiveWriterInner, +} + +enum ArchiveWriterInner { + Plain(File), + Zstd(zstd::stream::write::Encoder<'static, File>), +} + +impl ArchiveWriter { + pub fn create(archive_path: &Path, compression: CompressionMode) -> Result { + let file = File::create(archive_path).map_err(|e| { + TarError::TarOperationError(format!( + "Cannot create archive '{}': {}", + archive_path.display(), + e + )) + })?; + + let inner = match compression { + CompressionMode::None => ArchiveWriterInner::Plain(file), + CompressionMode::Zstd => { + let encoder = zstd::stream::write::Encoder::new(file, 0).map_err(|e| { + TarError::TarOperationError(format!( + "Failed to initialize zstd encoder for '{}': {}", + archive_path.display(), + e + )) + })?; + ArchiveWriterInner::Zstd(encoder) + } + }; + + Ok(Self { inner }) + } + + pub fn finish(self) -> Result<(), TarError> { + match self.inner { + ArchiveWriterInner::Plain(mut file) => file.flush().map_err(TarError::from), + ArchiveWriterInner::Zstd(encoder) => encoder.finish().map(|_| ()).map_err(|e| { + TarError::TarOperationError(format!("Failed to finalize zstd archive: {e}")) + }), + } + } +} + +impl Write for ArchiveWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match &mut self.inner { + ArchiveWriterInner::Plain(file) => file.write(buf), + ArchiveWriterInner::Zstd(encoder) => encoder.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match &mut self.inner { + ArchiveWriterInner::Plain(file) => file.flush(), + ArchiveWriterInner::Zstd(encoder) => encoder.flush(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use tempfile::tempdir; + + #[test] + fn test_plain_archive_writer_and_reader() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("plain.tar"); + + let mut writer = ArchiveWriter::create(&archive_path, CompressionMode::None).unwrap(); + writer.write_all(b"plain data").unwrap(); + writer.flush().unwrap(); + writer.finish().unwrap(); + + let mut reader = open_archive_reader(&archive_path, CompressionMode::None).unwrap(); + let mut contents = Vec::new(); + reader.read_to_end(&mut contents).unwrap(); + assert_eq!(contents, b"plain data"); + } + + #[test] + fn test_zstd_archive_writer_and_reader() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + + let mut writer = ArchiveWriter::create(&archive_path, CompressionMode::Zstd).unwrap(); + writer.write_all(b"zstd data").unwrap(); + writer.flush().unwrap(); + writer.finish().unwrap(); + + let mut reader = open_archive_reader(&archive_path, CompressionMode::Zstd).unwrap(); + let mut contents = Vec::new(); + reader.read_to_end(&mut contents).unwrap(); + assert_eq!(contents, b"zstd data"); + } + + #[test] + fn test_open_archive_reader_missing_file() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("missing.tar.zst"); + + let err = open_archive_reader(&archive_path, CompressionMode::Zstd) + .err() + .unwrap(); + assert!(matches!(err, TarError::FileNotFound { path: _ })); + } + + #[test] + fn test_open_archive_reader_invalid_zstd_stream_fails_on_read() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("invalid.tar.zst"); + std::fs::write(&archive_path, b"not zstd").unwrap(); + + let mut reader = open_archive_reader(&archive_path, CompressionMode::Zstd).unwrap(); + let mut contents = Vec::new(); + assert!(reader.read_to_end(&mut contents).is_err()); + } +} diff --git a/src/uu/tar/src/operations/create.rs b/src/uu/tar/src/operations/create.rs index a33d801..43bd034 100644 --- a/src/uu/tar/src/operations/create.rs +++ b/src/uu/tar/src/operations/create.rs @@ -4,8 +4,10 @@ // file that was distributed with this source code. use crate::errors::TarError; +use crate::operations::compression::ArchiveWriter; +use crate::CompressionMode; use std::collections::VecDeque; -use std::fs::{self, File}; +use std::fs; use std::io::{self, BufWriter, Write}; use std::path::Component::{self, ParentDir, Prefix, RootDir}; use std::path::{self, Path, PathBuf}; @@ -26,15 +28,14 @@ use uucore::error::UResult; /// - The archive file cannot be created /// - Any input file cannot be read /// - Files cannot be added due to I/O or permission errors -pub fn create_archive(archive_path: &Path, files: &[&Path], verbose: bool) -> UResult<()> { - // Create the output file - let file = File::create(archive_path).map_err(|e| TarError::CannotCreateArchive { - path: archive_path.to_path_buf(), - source: e, - })?; - - // Create Builder instance - let mut builder = Builder::new(file); +pub fn create_archive( + archive_path: &Path, + files: &[&Path], + verbose: bool, + compression: CompressionMode, +) -> UResult<()> { + let writer = ArchiveWriter::create(archive_path, compression)?; + let mut builder = Builder::new(writer); let mut out = BufWriter::new(io::stdout().lock()); // Add each file or directory to the archive @@ -106,7 +107,10 @@ pub fn create_archive(archive_path: &Path, files: &[&Path], verbose: bool) -> UR // Finish writing the archive out.flush().map_err(TarError::Io)?; - builder.finish().map_err(TarError::CannotFinalizeArchive)?; + let writer = builder + .into_inner() + .map_err(|e| TarError::TarOperationError(format!("Failed to finalize archive: {e}")))?; + writer.finish()?; Ok(()) } @@ -140,3 +144,50 @@ fn normalize_path(path: &Path) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CompressionMode; + use std::fs; + use tar::Archive; + use tempfile::tempdir; + + #[test] + fn test_create_archive_with_zstd() { + let tempdir = tempdir().unwrap(); + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + fs::write("file.txt", "hello").unwrap(); + + create_archive( + Path::new("archive.tar.zst"), + &[Path::new("file.txt")], + false, + CompressionMode::Zstd, + ) + .unwrap(); + + let decoder = + zstd::stream::read::Decoder::new(fs::File::open("archive.tar.zst").unwrap()).unwrap(); + let mut archive = Archive::new(decoder); + let mut entries = archive.entries().unwrap(); + let entry = entries.next().unwrap().unwrap(); + assert_eq!(entry.path().unwrap().to_str(), Some("file.txt")); + } + + #[test] + fn test_create_archive_missing_file_fails() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + let missing_path = tempdir.path().join("missing.txt"); + + let err = create_archive( + &archive_path, + &[missing_path.as_path()], + false, + CompressionMode::Zstd, + ) + .unwrap_err(); + assert!(err.to_string().contains("missing.txt")); + } +} diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index 135b66f..fd418c0 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -4,7 +4,8 @@ // file that was distributed with this source code. use crate::errors::TarError; -use std::fs::File; +use crate::operations::compression::open_archive_reader; +use crate::CompressionMode; use std::io::{self, BufWriter, Write}; use std::path::Path; use tar::Archive; @@ -23,12 +24,13 @@ use uucore::error::UResult; /// - The archive file cannot be opened /// - The archive format is invalid /// - Files cannot be extracted due to I/O or permission errors -pub fn extract_archive(archive_path: &Path, verbose: bool) -> UResult<()> { - // Open the archive file - let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; - - // Create Archive instance - let mut archive = Archive::new(file); +pub fn extract_archive( + archive_path: &Path, + verbose: bool, + compression: CompressionMode, +) -> UResult<()> { + let reader = open_archive_reader(archive_path, compression)?; + let mut archive = Archive::new(reader); let mut out = BufWriter::new(io::stdout().lock()); // Extract to current directory @@ -60,3 +62,42 @@ pub fn extract_archive(archive_path: &Path, verbose: bool) -> UResult<()> { out.flush().map_err(TarError::Io)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CompressionMode; + use std::fs; + use tar::Builder; + use tempfile::tempdir; + + #[test] + fn test_extract_archive_with_zstd() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + + let mut tar_bytes = Vec::new(); + { + let mut builder = Builder::new(&mut tar_bytes); + let mut header = tar::Header::new_gnu(); + header.set_mode(0o644); + header.set_size("hello".len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "extracted.txt", std::io::Cursor::new("hello")) + .unwrap(); + builder.finish().unwrap(); + } + let compressed = zstd::stream::encode_all(std::io::Cursor::new(tar_bytes), 0).unwrap(); + fs::write(&archive_path, compressed).unwrap(); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let result = extract_archive(&archive_path, true, CompressionMode::Zstd); + + result.unwrap(); + assert_eq!( + fs::read_to_string(tempdir.path().join("extracted.txt")).unwrap(), + "hello" + ); + } +} diff --git a/src/uu/tar/src/operations/list.rs b/src/uu/tar/src/operations/list.rs index c424ca8..1175be4 100644 --- a/src/uu/tar/src/operations/list.rs +++ b/src/uu/tar/src/operations/list.rs @@ -4,8 +4,9 @@ // file that was distributed with this source code. use crate::errors::TarError; +use crate::operations::compression::open_archive_reader; +use crate::CompressionMode; use chrono::{TimeZone, Utc}; -use std::fs::File; use std::io::{self, BufWriter, Write}; use std::path::Path; use tar::Archive; @@ -13,10 +14,13 @@ use uucore::error::UResult; use uucore::fs::display_permissions_unix; /// List the contents of a tar archive, printing one entry per line. -pub fn list_archive(archive_path: &Path, verbose: bool) -> UResult<()> { - let file: File = - File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; - let mut archive = Archive::new(file); +pub fn list_archive( + archive_path: &Path, + verbose: bool, + compression: CompressionMode, +) -> UResult<()> { + let reader = open_archive_reader(archive_path, compression)?; + let mut archive = Archive::new(reader); let mut out = BufWriter::new(io::stdout().lock()); for entry_result in archive.entries().map_err(TarError::CannotReadEntries)? { @@ -86,3 +90,47 @@ pub fn list_archive(archive_path: &Path, verbose: bool) -> UResult<()> { out.flush().map_err(TarError::Io)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CompressionMode; + use std::fs; + use tar::Builder; + use tempfile::tempdir; + + fn write_zstd_tar(archive_path: &Path) { + let mut tar_bytes = Vec::new(); + { + let mut builder = Builder::new(&mut tar_bytes); + let mut header = tar::Header::new_gnu(); + header.set_mode(0o644); + header.set_size("hello".len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "listed.txt", std::io::Cursor::new("hello")) + .unwrap(); + builder.finish().unwrap(); + } + let compressed = zstd::stream::encode_all(std::io::Cursor::new(tar_bytes), 0).unwrap(); + fs::write(archive_path, compressed).unwrap(); + } + + #[test] + fn test_list_archive_with_zstd_non_verbose() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + write_zstd_tar(&archive_path); + + list_archive(&archive_path, false, CompressionMode::Zstd).unwrap(); + } + + #[test] + fn test_list_archive_with_zstd_verbose() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + write_zstd_tar(&archive_path); + + list_archive(&archive_path, true, CompressionMode::Zstd).unwrap(); + } +} diff --git a/src/uu/tar/src/operations/mod.rs b/src/uu/tar/src/operations/mod.rs index a17f6f0..5105c26 100644 --- a/src/uu/tar/src/operations/mod.rs +++ b/src/uu/tar/src/operations/mod.rs @@ -3,6 +3,44 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +#[cfg(test)] +use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::sync::{Mutex, MutexGuard, OnceLock}; + +pub mod compression; pub mod create; pub mod extract; pub mod list; + +#[cfg(test)] +pub(crate) fn test_cwd_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +#[cfg(test)] +pub(crate) struct TestDirGuard { + old_dir: PathBuf, + _guard: MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl TestDirGuard { + pub(crate) fn enter(path: &Path) -> Self { + let guard = test_cwd_lock().lock().unwrap(); + let old_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { + old_dir, + _guard: guard, + } + } +} + +#[cfg(test)] +impl Drop for TestDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.old_dir); + } +} diff --git a/src/uu/tar/src/tar.rs b/src/uu/tar/src/tar.rs index 16e29d1..c5e2d56 100644 --- a/src/uu/tar/src/tar.rs +++ b/src/uu/tar/src/tar.rs @@ -14,6 +14,12 @@ use uucore::format_usage; const ABOUT: &str = "an archiving utility"; const USAGE: &str = "tar key [FILE...]\n tar {-c|-t|-x} [-v] -f ARCHIVE [FILE...]"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CompressionMode { + None, + Zstd, +} + /// Determines whether a string looks like a POSIX tar keystring. /// /// A valid keystring must not start with '-', must contain at least one @@ -131,6 +137,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { }; let verbose = matches.get_flag("verbose"); + let compression = if matches.get_flag("zstd") { + CompressionMode::Zstd + } else { + CompressionMode::None + }; // Handle extract operation if matches.get_flag("extract") { @@ -138,7 +149,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::error::USimpleError::new(64, "option requires an argument -- 'f'") })?; - return operations::extract::extract_archive(archive_path, verbose); + return operations::extract::extract_archive(archive_path, verbose, compression); } // Handle create operation @@ -159,7 +170,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )); } - return operations::create::create_archive(archive_path, &files, verbose); + return operations::create::create_archive(archive_path, &files, verbose, compression); } // Handle list operation @@ -168,7 +179,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::error::USimpleError::new(64, "option requires an argument -- 'f'") })?; - return operations::list::list_archive(archive_path, verbose); + return operations::list::list_archive(archive_path, verbose, compression); } // If no operation specified, show error @@ -203,6 +214,7 @@ pub fn uu_app() -> Command { // arg!(-z --gzip "Filter through gzip"), // arg!(-j --bzip2 "Filter through bzip2"), // arg!(-J --xz "Filter through xz"), + arg!(--zstd "Filter through zstd"), // Common options arg!(-v --verbose "Verbosely list files processed"), // arg!(-h --dereference "Follow symlinks"), @@ -220,6 +232,9 @@ pub fn uu_app() -> Command { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsString; + use std::fs; + use tempfile::tempdir; // --- is_posix_keystring --- @@ -328,4 +343,46 @@ mod tests { let expected = osvec(&["tar", "-c", "-b", "20", "-f", "archive.tar", "file.txt"]); assert_eq!(expand_posix_keystring(input), expected); } + + #[test] + fn test_uumain_dispatches_zstd_create_list_extract() { + let tempdir = tempdir().unwrap(); + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + fs::write("file.txt", "hello").unwrap(); + + let create_args = vec![ + OsString::from("test-bin"), + OsString::from("tar"), + OsString::from("--zstd"), + OsString::from("-cf"), + OsString::from("archive.tar.zst"), + OsString::from("file.txt"), + ]; + assert_eq!(uumain(create_args.into_iter()), 0); + + let list_args = vec![ + OsString::from("test-bin"), + OsString::from("tar"), + OsString::from("--zstd"), + OsString::from("-tf"), + OsString::from("archive.tar.zst"), + ]; + assert_eq!(uumain(list_args.into_iter()), 0); + + fs::remove_file("file.txt").unwrap(); + let extract_args = vec![ + OsString::from("test-bin"), + OsString::from("tar"), + OsString::from("--zstd"), + OsString::from("-xf"), + OsString::from("archive.tar.zst"), + ]; + let result = uumain(extract_args.into_iter()); + + assert_eq!(result, 0); + assert_eq!( + fs::read_to_string(tempdir.path().join("file.txt")).unwrap(), + "hello" + ); + } } diff --git a/src/uu/tar/tests/test_cli.rs b/src/uu/tar/tests/test_cli.rs index 9b42cd7..d63b846 100644 --- a/src/uu/tar/tests/test_cli.rs +++ b/src/uu/tar/tests/test_cli.rs @@ -32,3 +32,14 @@ fn test_verbose_flag_parsing() { assert!(matches.get_flag("verbose")); assert!(matches.get_flag("create")); } + +#[test] +fn test_zstd_flag_parsing() { + let app = uu_app(); + let result = + app.try_get_matches_from(vec!["tar", "--zstd", "-cf", "archive.tar.zst", "file.txt"]); + assert!(result.is_ok()); + let matches = result.unwrap(); + assert!(matches.get_flag("zstd")); + assert!(matches.get_flag("create")); +} diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 51395a3..f114cc1 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -3,8 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use std::io::{Cursor, Read}; use std::path::{self, PathBuf}; +use tar_rs_crate::{Archive as TarRsArchive, Builder as TarRsBuilder, Header as TarRsHeader}; use uutests::{at_and_ucmd, new_ucmd}; /// Size of a single tar block in bytes (per POSIX specification). @@ -114,6 +116,44 @@ fn test_create_multiple_files() { assert!(at.read_bytes("archive.tar").len() > TAR_BLOCK_SIZE); // Basic sanity check } +#[test] +fn test_create_zstd_archive() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file1.txt", "test content"); + + ucmd.args(&["--zstd", "-cf", "archive.tar.zst", "file1.txt"]) + .succeeds() + .no_output(); + + assert!(at.file_exists("archive.tar.zst")); + assert!(!at.read_bytes("archive.tar.zst").is_empty()); +} + +#[test] +fn test_create_zstd_archive_is_readable_by_independent_readers() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file1.txt", "test content"); + + ucmd.args(&["--zstd", "-cf", "archive.tar.zst", "file1.txt"]) + .succeeds() + .no_output(); + + let compressed = at.read_bytes("archive.tar.zst"); + let decoded = zstd::stream::decode_all(Cursor::new(compressed)).unwrap(); + let mut archive = TarRsArchive::new(Cursor::new(decoded)); + let mut entries = archive.entries().unwrap(); + let mut entry = entries.next().unwrap().unwrap(); + + assert_eq!(entry.path().unwrap().to_str(), Some("file1.txt")); + + let mut contents = String::new(); + entry.read_to_string(&mut contents).unwrap(); + assert_eq!(contents, "test content"); + assert!(entries.next().is_none()); +} + #[test] fn test_create_directory() { let (at, mut ucmd) = at_and_ucmd!(); @@ -266,6 +306,125 @@ fn test_extract_multiple_files() { assert_eq!(at.read("file2.txt"), "content2"); } +#[test] +fn test_list_zstd_archive() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file1.txt", "content1"); + at.write("file2.txt", "content2"); + + ucmd.args(&["--zstd", "-cf", "archive.tar.zst", "file1.txt", "file2.txt"]) + .succeeds(); + + new_ucmd!() + .args(&["--zstd", "-tf", "archive.tar.zst"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("file1.txt") + .stdout_contains("file2.txt"); +} + +#[test] +fn test_list_zstd_archive_created_outside_tar() { + let at = &at_and_ucmd!().0; + + let mut tar_bytes = Vec::new(); + { + let mut builder = TarRsBuilder::new(&mut tar_bytes); + let mut header = TarRsHeader::new_gnu(); + header.set_mode(0o644); + header.set_size("content".len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "external.txt", Cursor::new("content")) + .unwrap(); + builder.finish().unwrap(); + } + + let compressed = zstd::stream::encode_all(Cursor::new(tar_bytes), 0).unwrap(); + at.write_bytes("external.tar.zst", &compressed); + + new_ucmd!() + .args(&["--zstd", "-tf", "external.tar.zst"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("external.txt"); +} + +#[test] +fn test_extract_zstd_archive() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("original.txt", "test content"); + ucmd.args(&["--zstd", "-cf", "archive.tar.zst", "original.txt"]) + .succeeds(); + + at.remove("original.txt"); + + new_ucmd!() + .args(&["--zstd", "-xf", "archive.tar.zst"]) + .current_dir(at.as_string()) + .succeeds() + .no_output(); + + assert!(at.file_exists("original.txt")); + assert_eq!(at.read("original.txt"), "test content"); +} + +#[test] +fn test_list_zstd_archive_without_flag_fails() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file1.txt", "content1"); + ucmd.args(&["--zstd", "-cf", "archive.tar.zst", "file1.txt"]) + .succeeds(); + + new_ucmd!() + .args(&["-tf", "archive.tar.zst"]) + .current_dir(at.as_string()) + .fails() + .code_is(2); +} + +#[test] +fn test_list_invalid_zstd_archive_fails() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("invalid.tar.zst", "definitely not zstd"); + + ucmd.args(&["--zstd", "-tf", "invalid.tar.zst"]) + .fails() + .code_is(2); +} + +#[test] +fn test_list_truncated_zstd_archive_fails() { + let at = &at_and_ucmd!().0; + + let mut tar_bytes = Vec::new(); + { + let mut builder = TarRsBuilder::new(&mut tar_bytes); + let mut header = TarRsHeader::new_gnu(); + header.set_mode(0o644); + header.set_size("content".len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, "truncated.txt", Cursor::new("content")) + .unwrap(); + builder.finish().unwrap(); + } + + let mut compressed = zstd::stream::encode_all(Cursor::new(tar_bytes), 0).unwrap(); + compressed.truncate(compressed.len().saturating_sub(2)); + at.write_bytes("truncated.tar.zst", &compressed); + + new_ucmd!() + .args(&["--zstd", "-tf", "truncated.tar.zst"]) + .current_dir(at.as_string()) + .fails() + .code_is(2); +} + #[test] fn test_extract_nonexistent_archive() { new_ucmd!()