diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f7fcaf..ae963c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: CODECOV_FLAGS=$( echo "${{ matrix.job.os }}" | sed 's/[^[:alnum:]]/_/g' ) outputs CODECOV_FLAGS - name: Test - run: cargo test --no-fail-fast + run: cargo test --all --no-fail-fast env: RUSTC_WRAPPER: "" RUSTFLAGS: "-Cinstrument-coverage -Zcoverage-options=branch -Ccodegen-units=1 -Copt-level=0 -Coverflow-checks=off -Zpanic_abort_tests -Cpanic=abort" diff --git a/Cargo.lock b/Cargo.lock index 2bc1767..b84f1f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,6 +1226,7 @@ dependencies = [ "uutests", "xattr", "zip", + "zstd", ] [[package]] @@ -1405,7 +1406,9 @@ dependencies = [ "clap", "regex", "tar", + "tempfile", "uucore", + "zstd", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5a0c695..6c37dcb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ tempfile = "3.10.1" textwrap = { version = "0.16.1", features = ["terminal_size"] } xattr = "1.3.1" zip = "8.0" +zstd = "0.13.3" [dependencies] clap = { workspace = true } @@ -74,6 +75,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 45ee8cc..50ae5d8 100644 --- a/src/uu/tar/Cargo.toml +++ b/src/uu/tar/Cargo.toml @@ -18,6 +18,7 @@ clap = { workspace = true } regex = { workspace = true } tar = { workspace = true } chrono = { workspace = true } +zstd = { workspace = true } [lib] path = "src/tar.rs" @@ -25,3 +26,6 @@ path = "src/tar.rs" [[bin]] name = "tar" path = "src/main.rs" + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/src/uu/tar/src/operations/compression.rs b/src/uu/tar/src/operations/compression.rs new file mode 100644 index 0000000..272debb --- /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(_))); + } + + #[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 2cde873..e00e647 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::path::Component::{self, ParentDir, Prefix, RootDir}; use std::path::{self, Path, PathBuf}; use tar::Builder; @@ -25,18 +27,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::TarOperationError(format!( - "Cannot create archive '{}': {}", - archive_path.display(), - 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); // Add each file or directory to the archive for &path in files { @@ -102,9 +100,10 @@ pub fn create_archive(archive_path: &Path, files: &[&Path], verbose: bool) -> UR } // Finish writing the archive - builder - .finish() + let writer = builder + .into_inner() .map_err(|e| TarError::TarOperationError(format!("Failed to finalize archive: {e}")))?; + writer.finish()?; Ok(()) } @@ -138,3 +137,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 2668009..fff02e9 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::path::Path; use tar::Archive; use uucore::error::UResult; @@ -22,12 +23,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); // Extract to current directory if verbose { @@ -60,3 +62,42 @@ pub fn extract_archive(archive_path: &Path, verbose: bool) -> UResult<()> { 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 6826a50..302177d 100644 --- a/src/uu/tar/src/operations/list.rs +++ b/src/uu/tar/src/operations/list.rs @@ -4,18 +4,22 @@ // 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::path::Path; use tar::Archive; 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); for entry_result in archive .entries() @@ -89,3 +93,47 @@ pub fn list_archive(archive_path: &Path, verbose: bool) -> UResult<()> { 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 6b8e506..4e21717 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!()