From 252d371f2b05c79c0a0c6d681ba55067ba1bf2f9 Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:29:22 +0300 Subject: [PATCH 1/3] extract: add -O/--to-stdout With -O, the contents of regular members are written to standard output instead of being created on disk, like GNU tar. Other member types are skipped, and -v output goes to standard error so it doesn't mix with the contents. Closes #196 --- benches/bench_tar.rs | 1 + src/uu/tar/src/operations/extract.rs | 25 ++++++++++++++++- src/uu/tar/src/operations/extract_tests.rs | 2 +- src/uu/tar/src/tar.rs | 18 +++++++++++-- tests/by-util/test_tar.rs | 31 ++++++++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/benches/bench_tar.rs b/benches/bench_tar.rs index 638813c..d6ad000 100644 --- a/benches/bench_tar.rs +++ b/benches/bench_tar.rs @@ -186,6 +186,7 @@ fn extract_archive_20_files(bencher: divan::Bencher) { input, &archive_path, false, + false, CompressionMode::None, ) .unwrap(); diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index 23a55b4..ba1ec6a 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -18,6 +18,8 @@ use uucore::error::UResult; /// /// * `archive_path` - Path to the tar archive to extract /// * `verbose` - Whether to print verbose output during extraction +/// * `to_stdout` - Write the contents of regular files to standard output instead +/// of creating them; verbose output then goes to standard error /// /// # Errors /// @@ -29,11 +31,19 @@ pub fn extract_archive( input: impl Read, archive_path: &Path, verbose: bool, + to_stdout: bool, compression: CompressionMode, ) -> UResult<()> { let reader = open_archive_reader(input, compression)?; let mut archive = Archive::new(reader); - let mut out = BufWriter::new(io::stdout().lock()); + // With --to-stdout the member contents own stdout, so status messages go to stderr. + let status: Box = if to_stdout { + Box::new(io::stderr()) + } else { + Box::new(io::stdout().lock()) + }; + let mut out = BufWriter::new(status); + let mut data = BufWriter::new(io::stdout().lock()); // Extract to current directory if verbose { @@ -58,6 +68,18 @@ pub fn extract_archive( writeln!(out, "{}", path.display()).map_err(TarError::Io)?; } + if to_stdout { + // Like GNU tar, only regular files have contents to write. + if entry.header().entry_type().is_file() { + out.flush().map_err(TarError::Io)?; + io::copy(&mut entry, &mut data).map_err(|e| TarError::CannotExtract { + path: path.clone(), + source: e, + })?; + } + continue; + } + // Unpack the entry. `unpack_in` skips, and returns false for, a member whose // name contains a `..` component, as it would escape the target directory. let unpacked = entry.unpack_in(".").map_err(|e| TarError::CannotExtract { @@ -72,6 +94,7 @@ pub fn extract_archive( } out.flush().map_err(TarError::Io)?; + data.flush().map_err(TarError::Io)?; if refused_member { return finish_after_previous_errors(); } diff --git a/src/uu/tar/src/operations/extract_tests.rs b/src/uu/tar/src/operations/extract_tests.rs index 898caff..205974a 100644 --- a/src/uu/tar/src/operations/extract_tests.rs +++ b/src/uu/tar/src/operations/extract_tests.rs @@ -31,7 +31,7 @@ fn test_extract_archive_with_zstd() { let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); let input = fs::File::open(&archive_path).unwrap(); - let result = extract_archive(input, &archive_path, true, CompressionMode::Zstd); + let result = extract_archive(input, &archive_path, true, false, CompressionMode::Zstd); result.unwrap(); assert_eq!( diff --git a/src/uu/tar/src/tar.rs b/src/uu/tar/src/tar.rs index 60ce5a5..9a3081e 100644 --- a/src/uu/tar/src/tar.rs +++ b/src/uu/tar/src/tar.rs @@ -145,6 +145,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let verbose = matches.get_flag("verbose"); let allow_absolute = matches.get_flag("absolute-names"); + let to_stdout = matches.get_flag("to-stdout"); let explicit_compression = if matches.get_flag("gzip") { Some(CompressionMode::Gzip) } else if matches.get_flag("zstd") { @@ -161,11 +162,23 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let compression = explicit_compression.unwrap_or(CompressionMode::Auto); return if archive_path == Path::new("-") { - operations::extract::extract_archive(io::stdin(), archive_path, verbose, compression) + operations::extract::extract_archive( + io::stdin(), + archive_path, + verbose, + to_stdout, + compression, + ) } else { let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; - operations::extract::extract_archive(file, archive_path, verbose, compression) + operations::extract::extract_archive( + file, + archive_path, + verbose, + to_stdout, + compression, + ) }; } @@ -276,6 +289,7 @@ pub fn uu_app() -> Command { arg!(--zstd "Filter through zstd").conflicts_with("gzip"), // Common options arg!(-v --verbose "Verbosely list files processed"), + arg!(-O --"to-stdout" "Extract files to standard output"), // arg!(-h --dereference "Follow symlinks"), // arg!(-p --"preserve-permissions" "Extract information about file permissions"), // Help diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 975f523..3895254 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -86,6 +86,37 @@ fn test_create_dir_verbose() { .stdout_contains(file2_path); } +#[test] +fn test_extract_to_stdout() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.write("dir/a.txt", "first\n"); + at.write("dir/b.txt", "second\n"); + ucmd.args(&["-cf", "archive.tar", "dir/a.txt", "dir/b.txt"]) + .succeeds(); + at.remove("dir/a.txt"); + at.remove("dir/b.txt"); + + new_ucmd!() + .arg("-xOf") + .arg(at.plus("archive.tar")) + .succeeds() + .stdout_only("first\nsecond\n"); + assert!(!at.file_exists("dir/a.txt")); + assert!(!at.file_exists("dir/b.txt")); + + // With -v, the member names go to stderr so they don't mix with the contents + new_ucmd!() + .arg("-xvf") + .arg(at.plus("archive.tar")) + .arg("--to-stdout") + .succeeds() + .stdout_is("first\nsecond\n") + .stderr_contains("dir/a.txt") + .stderr_contains("dir/b.txt"); +} + #[test] fn test_create_single_file() { let (at, mut ucmd) = at_and_ucmd!(); From 367c2f10f2cc50fa3dc044ddb391fe49a9192162 Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:43:52 +0300 Subject: [PATCH 2/3] tests: run the -O test in one scenario so it checks the disk --- tests/by-util/test_tar.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 3895254..525ce38 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -7,7 +7,8 @@ use std::io::{Cursor, Read, Write}; 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}; +use uutests::util::TestScenario; +use uutests::{at_and_ucmd, new_ucmd, util_name}; /// Size of a single tar block in bytes (per POSIX specification). // TODO: This should be exported from the tar crate instead of being redefined here. @@ -88,33 +89,34 @@ fn test_create_dir_verbose() { #[test] fn test_extract_to_stdout() { - let (at, mut ucmd) = at_and_ucmd!(); + let ts = TestScenario::new(util_name!()); + let at = &ts.fixtures; at.mkdir("dir"); at.write("dir/a.txt", "first\n"); at.write("dir/b.txt", "second\n"); - ucmd.args(&["-cf", "archive.tar", "dir/a.txt", "dir/b.txt"]) + ts.ucmd() + .args(&["-cf", "archive.tar", "dir/a.txt", "dir/b.txt"]) .succeeds(); at.remove("dir/a.txt"); at.remove("dir/b.txt"); - new_ucmd!() - .arg("-xOf") - .arg(at.plus("archive.tar")) + ts.ucmd() + .args(&["-xOf", "archive.tar"]) .succeeds() .stdout_only("first\nsecond\n"); + // The contents went to stdout, so nothing was written to disk. assert!(!at.file_exists("dir/a.txt")); assert!(!at.file_exists("dir/b.txt")); // With -v, the member names go to stderr so they don't mix with the contents - new_ucmd!() - .arg("-xvf") - .arg(at.plus("archive.tar")) - .arg("--to-stdout") + ts.ucmd() + .args(&["-xvf", "archive.tar", "--to-stdout"]) .succeeds() .stdout_is("first\nsecond\n") .stderr_contains("dir/a.txt") .stderr_contains("dir/b.txt"); + assert!(!at.file_exists("dir/a.txt")); } #[test] From 41d123a6a166a14e7e990e9a769cbad9e709a8ab Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:56:37 +0300 Subject: [PATCH 3/3] extract: lock stdout once, flush each member under -O, test a dir member --- src/uu/tar/src/operations/extract.rs | 12 ++++++++---- tests/by-util/test_tar.rs | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index ba1ec6a..2821c8d 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -43,7 +43,7 @@ pub fn extract_archive( Box::new(io::stdout().lock()) }; let mut out = BufWriter::new(status); - let mut data = BufWriter::new(io::stdout().lock()); + let mut data = to_stdout.then(|| BufWriter::new(io::stdout().lock())); // Extract to current directory if verbose { @@ -68,14 +68,16 @@ pub fn extract_archive( writeln!(out, "{}", path.display()).map_err(TarError::Io)?; } - if to_stdout { + if let Some(data) = data.as_mut() { // Like GNU tar, only regular files have contents to write. if entry.header().entry_type().is_file() { out.flush().map_err(TarError::Io)?; - io::copy(&mut entry, &mut data).map_err(|e| TarError::CannotExtract { + io::copy(&mut entry, data).map_err(|e| TarError::CannotExtract { path: path.clone(), source: e, })?; + // Keep each member's contents next to its name under -v. + data.flush().map_err(TarError::Io)?; } continue; } @@ -94,7 +96,9 @@ pub fn extract_archive( } out.flush().map_err(TarError::Io)?; - data.flush().map_err(TarError::Io)?; + if let Some(data) = data.as_mut() { + data.flush().map_err(TarError::Io)?; + } if refused_member { return finish_after_previous_errors(); } diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 525ce38..e8fa00a 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -117,6 +117,18 @@ fn test_extract_to_stdout() { .stderr_contains("dir/a.txt") .stderr_contains("dir/b.txt"); assert!(!at.file_exists("dir/a.txt")); + + // A directory member has no contents, and -O doesn't create it either + at.mkdir("only"); + at.write("only/c.txt", "third\n"); + ts.ucmd().args(&["-cf", "dir.tar", "only"]).succeeds(); + at.remove("only/c.txt"); + at.rmdir("only"); + ts.ucmd() + .args(&["-xOf", "dir.tar"]) + .succeeds() + .stdout_only("third\n"); + assert!(!at.dir_exists("only")); } #[test]