From 71660a957b6b91f9febc41edb652fd45ff062b5d Mon Sep 17 00:00:00 2001 From: Nicolas NOSAL Date: Tue, 30 Jun 2026 12:05:41 +0200 Subject: [PATCH 1/5] tar: add --wildcards and --strip-components flags Implements two GNU tar compatibility flags for extract and list modes: - --wildcards: treat positional file arguments as glob patterns when filtering archive entries (* matches any string, ? matches any char) - --strip-components=N: strip N leading path components from entry paths; entries that become empty after stripping are silently skipped Shared helpers are added in operations/mod.rs: strip_leading_components(), wildcard_match(), entry_matches() Closes #186, closes #195 --- src/uu/tar/src/operations/extract.rs | 75 ++++++--- src/uu/tar/src/operations/extract_tests.rs | 140 +++++++++++++++-- src/uu/tar/src/operations/list.rs | 53 ++++++- src/uu/tar/src/operations/list_tests.rs | 103 +++++++++++-- src/uu/tar/src/operations/mod.rs | 154 ++++++++++++++++++- src/uu/tar/src/tar.rs | 58 ++++++- tests/by-util/test_tar.rs | 168 +++++++++++++++++++++ 7 files changed, 693 insertions(+), 58 deletions(-) diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index 4c36cdc..d3a64b3 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -8,57 +8,96 @@ use crate::errors::TarError; use crate::CompressionMode; use std::io::Read; use std::io::{self, BufWriter, Write}; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use tar::Archive; use uucore::error::UResult; -/// Extract files from a tar archive +use super::{entry_matches, strip_leading_components}; + +/// Extract files from a tar archive. /// /// # Arguments /// -/// * `archive_path` - Path to the tar archive to extract -/// * `verbose` - Whether to print verbose output during extraction +/// * `input` - Readable source of the archive data +/// * `archive_path` - Path used for error messages and verbose header +/// * `verbose` - Print each extracted path to stdout +/// * `compression` - Compression mode to use when reading +/// * `file_patterns` - If non-empty, only extract entries matching these names (or globs) +/// * `wildcards` - Treat `file_patterns` as glob patterns (`*`, `?`) +/// * `strip_components` - Strip this many leading path components before writing /// /// # Errors /// -/// Returns an error if: -/// - The archive file cannot be opened -/// - The archive format is invalid -/// - Files cannot be extracted due to I/O or permission errors +/// Returns an error if the archive cannot be read or entries cannot be extracted. pub fn extract_archive( input: impl Read, archive_path: &Path, verbose: bool, compression: CompressionMode, + file_patterns: &[PathBuf], + wildcards: bool, + strip_components: u32, ) -> UResult<()> { let reader = open_archive_reader(input, compression)?; let mut archive = Archive::new(reader); let mut out = BufWriter::new(io::stdout().lock()); - // Extract to current directory if verbose { writeln!(out, "Extracting archive: {}", archive_path.display()).map_err(TarError::Io)?; } - // Iterate through entries for verbose output and error handling for entry_result in archive.entries().map_err(TarError::CannotReadEntries)? { let mut entry = entry_result.map_err(TarError::CannotReadEntry)?; - // Get the path before unpacking (clone it so we can use it after borrowing entry mutably) let path = entry .path() .map_err(TarError::CannotReadEntryPath)? .to_path_buf(); - if verbose { - writeln!(out, "{}", path.display()).map_err(TarError::Io)?; + if !entry_matches(&path, file_patterns, wildcards) { + continue; } - // Unpack the entry - entry.unpack_in(".").map_err(|e| TarError::CannotExtract { - path: path.clone(), - source: e, - })?; + if strip_components > 0 { + let effective_path = match strip_leading_components(&path, strip_components) { + Some(p) => p, + None => continue, + }; + + // Reject paths that escape the destination after stripping. + if effective_path.is_absolute() + || effective_path + .components() + .any(|c| c == Component::ParentDir) + { + continue; + } + + if verbose { + writeln!(out, "{}", effective_path.display()).map_err(TarError::Io)?; + } + + if let Some(parent) = effective_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(TarError::Io)?; + } + } + entry + .unpack(&effective_path) + .map_err(|e| TarError::CannotExtract { + path: effective_path, + source: e, + })?; + } else { + if verbose { + writeln!(out, "{}", path.display()).map_err(TarError::Io)?; + } + + entry.unpack_in(".").map_err(|e| TarError::CannotExtract { + path: path.clone(), + source: e, + })?; + } } out.flush().map_err(TarError::Io)?; diff --git a/src/uu/tar/src/operations/extract_tests.rs b/src/uu/tar/src/operations/extract_tests.rs index 898caff..051e9f8 100644 --- a/src/uu/tar/src/operations/extract_tests.rs +++ b/src/uu/tar/src/operations/extract_tests.rs @@ -6,36 +6,146 @@ use super::*; use crate::CompressionMode; use std::fs; +use std::path::PathBuf; 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"); - +fn make_zstd_tar(archive_path: &std::path::Path, entries: &[(&str, &str)]) { 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(); + for (name, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_mode(0o644); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, name, std::io::Cursor::new(content)) + .unwrap(); + } builder.finish().unwrap(); } let compressed = zstd::stream::encode_all(std::io::Cursor::new(tar_bytes), 0).unwrap(); - fs::write(&archive_path, compressed).unwrap(); + fs::write(archive_path, compressed).unwrap(); +} + +#[test] +fn test_extract_archive_with_zstd() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + make_zstd_tar(&archive_path, &[("extracted.txt", "hello")]); 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); + extract_archive( + input, + &archive_path, + true, + CompressionMode::Zstd, + &[], + false, + 0, + ) + .unwrap(); - result.unwrap(); assert_eq!( fs::read_to_string(tempdir.path().join("extracted.txt")).unwrap(), "hello" ); } + +#[test] +fn test_extract_with_file_pattern_filter() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + make_zstd_tar(&archive_path, &[("keep.txt", "keep"), ("skip.txt", "skip")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[PathBuf::from("keep.txt")], + false, + 0, + ) + .unwrap(); + + assert!(tempdir.path().join("keep.txt").exists()); + assert!(!tempdir.path().join("skip.txt").exists()); +} + +#[test] +fn test_extract_with_wildcards() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + make_zstd_tar(&archive_path, &[("file.txt", "txt"), ("file.rs", "rs")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[PathBuf::from("*.txt")], + true, + 0, + ) + .unwrap(); + + assert!(tempdir.path().join("file.txt").exists()); + assert!(!tempdir.path().join("file.rs").exists()); +} + +#[test] +fn test_extract_with_strip_components() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + make_zstd_tar(&archive_path, &[("a/b/file.txt", "content")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[], + false, + 2, + ) + .unwrap(); + + assert!(tempdir.path().join("file.txt").exists()); + assert!(!tempdir.path().join("a").exists()); +} + +#[test] +fn test_extract_strip_components_skips_shallow_entries() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + // "root.txt" has only 1 component; stripping 2 should skip it entirely + make_zstd_tar( + &archive_path, + &[("root.txt", "shallow"), ("a/b/deep.txt", "deep")], + ); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[], + false, + 2, + ) + .unwrap(); + + assert!(!tempdir.path().join("root.txt").exists()); + assert!(tempdir.path().join("deep.txt").exists()); +} diff --git a/src/uu/tar/src/operations/list.rs b/src/uu/tar/src/operations/list.rs index 8f88b7b..6138f61 100644 --- a/src/uu/tar/src/operations/list.rs +++ b/src/uu/tar/src/operations/list.rs @@ -9,17 +9,36 @@ use crate::CompressionMode; use chrono::{TimeZone, Utc}; use std::io::Read; use std::io::{self, BufWriter, Write}; -use std::path::Path; +use std::path::{Path, PathBuf}; use tar::Archive; use uucore::error::UResult; use uucore::fs::display_permissions_unix; +use super::{entry_matches, strip_leading_components}; + /// List the contents of a tar archive, printing one entry per line. +/// +/// # Arguments +/// +/// * `input` - Readable source of the archive data +/// * `_archive_path` - Path used for error messages (reserved for future use) +/// * `verbose` - Print detailed metadata for each entry +/// * `compression` - Compression mode to use when reading +/// * `file_patterns` - If non-empty, only show entries matching these names (or globs) +/// * `wildcards` - Treat `file_patterns` as glob patterns (`*`, `?`) +/// * `strip_components` - Strip this many leading path components from displayed paths +/// +/// # Errors +/// +/// Returns an error if the archive cannot be read or an entry path cannot be decoded. pub fn list_archive( input: impl Read, _archive_path: &Path, verbose: bool, compression: CompressionMode, + file_patterns: &[PathBuf], + wildcards: bool, + strip_components: u32, ) -> UResult<()> { let reader = open_archive_reader(input, compression)?; let mut archive = Archive::new(reader); @@ -28,12 +47,29 @@ pub fn list_archive( for entry_result in archive.entries().map_err(TarError::CannotReadEntries)? { let entry = entry_result.map_err(TarError::CannotReadEntry)?; + let path = entry + .path() + .map_err(TarError::CannotReadEntryPath)? + .to_path_buf(); + + if !entry_matches(&path, file_patterns, wildcards) { + continue; + } + + let display_path = if strip_components > 0 { + match strip_leading_components(&path, strip_components) { + Some(p) => p, + None => continue, + } + } else { + path.clone() + }; + if verbose { - let formatted = format_verbose_entry(&entry)?; + let formatted = format_verbose_entry(&entry, &display_path)?; writeln!(out, "{formatted}").map_err(TarError::Io)?; } else { - let path = entry.path().map_err(TarError::CannotReadEntryPath)?; - writeln!(out, "{}", path.display()).map_err(TarError::Io)?; + writeln!(out, "{}", display_path.display()).map_err(TarError::Io)?; } } @@ -41,7 +77,10 @@ pub fn list_archive( Ok(()) } -fn format_verbose_entry(entry: &tar::Entry<'_, R>) -> Result { +fn format_verbose_entry( + entry: &tar::Entry<'_, R>, + display_path: &Path, +) -> Result { let (mode, entry_type, owner, group, size, mtime) = { let header = entry.header(); ( @@ -64,8 +103,6 @@ fn format_verbose_entry(entry: &tar::Entry<'_, R>) -> Result 'd', tar::EntryType::Symlink => 'l', @@ -85,7 +122,7 @@ fn format_verbose_entry(entry: &tar::Entry<'_, R>) -> Result8} {date_str} {}", - path.display() + display_path.display() )) } diff --git a/src/uu/tar/src/operations/list_tests.rs b/src/uu/tar/src/operations/list_tests.rs index 0bdb442..522d3a5 100644 --- a/src/uu/tar/src/operations/list_tests.rs +++ b/src/uu/tar/src/operations/list_tests.rs @@ -6,20 +6,23 @@ use super::*; use crate::CompressionMode; use std::fs; +use std::path::PathBuf; use tar::Builder; use tempfile::tempdir; -fn write_zstd_tar(archive_path: &Path) { +fn write_zstd_tar(archive_path: &Path, entries: &[(&str, &str)]) { 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(); + for (name, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_mode(0o644); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, name, std::io::Cursor::new(content)) + .unwrap(); + } builder.finish().unwrap(); } let compressed = zstd::stream::encode_all(std::io::Cursor::new(tar_bytes), 0).unwrap(); @@ -30,18 +33,94 @@ fn write_zstd_tar(archive_path: &Path) { 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); + write_zstd_tar(&archive_path, &[("listed.txt", "hello")]); let input = fs::File::open(&archive_path).unwrap(); - list_archive(input, &archive_path, false, CompressionMode::Zstd).unwrap(); + list_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[], + false, + 0, + ) + .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); + write_zstd_tar(&archive_path, &[("listed.txt", "hello")]); let input = fs::File::open(&archive_path).unwrap(); - list_archive(input, &archive_path, true, CompressionMode::Zstd).unwrap(); + list_archive( + input, + &archive_path, + true, + CompressionMode::Zstd, + &[], + false, + 0, + ) + .unwrap(); +} + +#[test] +fn test_list_with_file_pattern_filter() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + write_zstd_tar(&archive_path, &[("a.txt", "a"), ("b.txt", "b")]); + + let input = fs::File::open(&archive_path).unwrap(); + // Should not panic; filtering is tested at the integration level + list_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[PathBuf::from("a.txt")], + false, + 0, + ) + .unwrap(); +} + +#[test] +fn test_list_with_wildcards() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + write_zstd_tar(&archive_path, &[("foo.txt", "f"), ("bar.rs", "b")]); + + let input = fs::File::open(&archive_path).unwrap(); + list_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[PathBuf::from("*.txt")], + true, + 0, + ) + .unwrap(); +} + +#[test] +fn test_list_with_strip_components() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + write_zstd_tar(&archive_path, &[("a/b/file.txt", "x")]); + + let input = fs::File::open(&archive_path).unwrap(); + list_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[], + false, + 2, + ) + .unwrap(); } diff --git a/src/uu/tar/src/operations/mod.rs b/src/uu/tar/src/operations/mod.rs index 1890553..be05629 100644 --- a/src/uu/tar/src/operations/mod.rs +++ b/src/uu/tar/src/operations/mod.rs @@ -3,7 +3,6 @@ // 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}; @@ -12,6 +11,70 @@ pub mod create; pub mod extract; pub mod list; +/// Strip `n` leading path components from `path`. +/// Returns `None` if all components are consumed (entry should be skipped). +pub fn strip_leading_components(path: &Path, n: u32) -> Option { + if n == 0 { + return Some(path.to_path_buf()); + } + let mut components = path.components(); + for _ in 0..n { + components.next()?; + } + let remaining: PathBuf = components.collect(); + if remaining.as_os_str().is_empty() { + None + } else { + Some(remaining) + } +} + +/// Match `text` against a shell glob `pattern` (`*` = any string, `?` = any char). +pub fn wildcard_match(pattern: &str, text: &str) -> bool { + let mut regex_str = String::from("^"); + for c in pattern.chars() { + match c { + '*' => regex_str.push_str(".*"), + '?' => regex_str.push('.'), + c => regex_str.push_str(®ex::escape(&c.to_string())), + } + } + regex_str.push('$'); + regex::Regex::new(®ex_str) + .map(|r| r.is_match(text)) + .unwrap_or(false) +} + +/// Check whether an entry path matches the given list of patterns. +/// If `patterns` is empty, all entries match. Without wildcards, a pattern +/// matches entries whose normalised path equals the pattern or whose path +/// is contained in the named directory. +pub fn entry_matches(path: &Path, patterns: &[PathBuf], wildcards: bool) -> bool { + if patterns.is_empty() { + return true; + } + let path_str = path.to_string_lossy(); + let normalised = path_str.trim_end_matches('/'); + for pattern in patterns { + let pat = pattern.to_string_lossy(); + if wildcards { + if wildcard_match(&pat, &path_str) || wildcard_match(&pat, normalised) { + return true; + } + } else { + let norm_pat = pat.trim_end_matches('/'); + if normalised == norm_pat { + return true; + } + // entry lives inside the named directory + if path_str.starts_with(&format!("{}/", norm_pat)) { + return true; + } + } + } + false +} + #[cfg(test)] pub(crate) fn test_cwd_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -43,3 +106,92 @@ impl Drop for TestDirGuard { let _ = std::env::set_current_dir(&self.old_dir); } } + +#[cfg(test)] +mod tests { + use super::*; + + // strip_leading_components + + #[test] + fn test_strip_zero_components() { + let p = PathBuf::from("a/b/c.txt"); + assert_eq!(strip_leading_components(&p, 0), Some(p)); + } + + #[test] + fn test_strip_one_component() { + assert_eq!( + strip_leading_components(Path::new("a/b/c.txt"), 1), + Some(PathBuf::from("b/c.txt")) + ); + } + + #[test] + fn test_strip_all_components_returns_none() { + assert_eq!(strip_leading_components(Path::new("a/b"), 3), None); + } + + #[test] + fn test_strip_exact_components_returns_none() { + // "a/b" stripped by 2 leaves an empty path + assert_eq!(strip_leading_components(Path::new("a/b"), 2), None); + } + + // wildcard_match + + #[test] + fn test_wildcard_star_matches_any_string() { + assert!(wildcard_match("*.txt", "file.txt")); + assert!(wildcard_match("*.txt", "dir/file.txt")); + } + + #[test] + fn test_wildcard_question_matches_single_char() { + assert!(wildcard_match("file?.txt", "file1.txt")); + assert!(!wildcard_match("file?.txt", "file10.txt")); + } + + #[test] + fn test_wildcard_no_match() { + assert!(!wildcard_match("*.rs", "file.txt")); + } + + #[test] + fn test_wildcard_exact_literal() { + assert!(wildcard_match("exact.txt", "exact.txt")); + assert!(!wildcard_match("exact.txt", "other.txt")); + } + + // entry_matches + + #[test] + fn test_entry_matches_empty_patterns_matches_all() { + assert!(entry_matches(Path::new("anything.txt"), &[], false)); + } + + #[test] + fn test_entry_matches_exact() { + let patterns = vec![PathBuf::from("file.txt")]; + assert!(entry_matches(Path::new("file.txt"), &patterns, false)); + assert!(!entry_matches(Path::new("other.txt"), &patterns, false)); + } + + #[test] + fn test_entry_matches_directory_prefix() { + let patterns = vec![PathBuf::from("dir")]; + assert!(entry_matches(Path::new("dir/file.txt"), &patterns, false)); + assert!(!entry_matches( + Path::new("other/file.txt"), + &patterns, + false + )); + } + + #[test] + fn test_entry_matches_wildcard() { + let patterns = vec![PathBuf::from("*.txt")]; + assert!(entry_matches(Path::new("file.txt"), &patterns, true)); + assert!(!entry_matches(Path::new("file.rs"), &patterns, true)); + } +} diff --git a/src/uu/tar/src/tar.rs b/src/uu/tar/src/tar.rs index 60ce5a5..be7e769 100644 --- a/src/uu/tar/src/tar.rs +++ b/src/uu/tar/src/tar.rs @@ -145,6 +145,11 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let verbose = matches.get_flag("verbose"); let allow_absolute = matches.get_flag("absolute-names"); + let wildcards = matches.get_flag("wildcards"); + let strip_components = matches + .get_one::("strip-components") + .copied() + .unwrap_or(0); let explicit_compression = if matches.get_flag("gzip") { Some(CompressionMode::Gzip) } else if matches.get_flag("zstd") { @@ -159,13 +164,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::error::USimpleError::new(64, "option requires an argument -- 'f'") })?; + let file_patterns: Vec = matches + .get_many::("files") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + 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, + compression, + &file_patterns, + wildcards, + strip_components, + ) } 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, + compression, + &file_patterns, + wildcards, + strip_components, + ) }; } @@ -227,13 +253,34 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { uucore::error::USimpleError::new(64, "option requires an argument -- 'f'") })?; + let file_patterns: Vec = matches + .get_many::("files") + .map(|v| v.cloned().collect()) + .unwrap_or_default(); + let compression = explicit_compression.unwrap_or(CompressionMode::Auto); return if archive_path == Path::new("-") { - operations::list::list_archive(io::stdin(), archive_path, verbose, compression) + operations::list::list_archive( + io::stdin(), + archive_path, + verbose, + compression, + &file_patterns, + wildcards, + strip_components, + ) } else { let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; - operations::list::list_archive(file, archive_path, verbose, compression) + operations::list::list_archive( + file, + archive_path, + verbose, + compression, + &file_patterns, + wildcards, + strip_components, + ) }; } @@ -278,6 +325,9 @@ pub fn uu_app() -> Command { arg!(-v --verbose "Verbosely list files processed"), // arg!(-h --dereference "Follow symlinks"), // arg!(-p --"preserve-permissions" "Extract information about file permissions"), + arg!(--wildcards "Use wildcards when matching file names"), + arg!(--"strip-components" "Strip NUMBER leading components from file names") + .value_parser(clap::value_parser!(u32)), // Help arg!(--help "Print help information").action(ArgAction::Help), // Files to process diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 23cefa3..eacd9a1 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -1326,3 +1326,171 @@ fn test_extract_invalid_gzip_archive_fails() { ucmd.args(&["-xf", "invalid.tar.gz"]).fails().code_is(2); } + +// --wildcards tests + +#[test] +fn test_list_wildcards_star_pattern() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file.txt", "txt"); + at.write("file.rs", "rs"); + + ucmd.args(&["-cf", "archive.tar", "file.txt", "file.rs"]) + .succeeds(); + + new_ucmd!() + .args(&["-tf", "archive.tar", "--wildcards", "*.txt"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("file.txt") + .stdout_does_not_contain("file.rs"); +} + +#[test] +fn test_list_wildcards_question_mark_pattern() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file1.txt", "1"); + at.write("file2.txt", "2"); + at.write("file10.txt", "10"); + + ucmd.args(&["-cf", "archive.tar", "file1.txt", "file2.txt", "file10.txt"]) + .succeeds(); + + new_ucmd!() + .args(&["-tf", "archive.tar", "--wildcards", "file?.txt"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("file1.txt") + .stdout_contains("file2.txt") + .stdout_does_not_contain("file10.txt"); +} + +#[test] +fn test_extract_wildcards() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("keep.txt", "keep"); + at.write("skip.rs", "skip"); + + ucmd.args(&["-cf", "archive.tar", "keep.txt", "skip.rs"]) + .succeeds(); + + at.remove("keep.txt"); + at.remove("skip.rs"); + + new_ucmd!() + .args(&["-xf", "archive.tar", "--wildcards", "*.txt"]) + .current_dir(at.as_string()) + .succeeds(); + + assert!(at.file_exists("keep.txt")); + assert!(!at.file_exists("skip.rs")); +} + +#[test] +fn test_list_no_wildcards_exact_match() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file.txt", "a"); + at.write("other.txt", "b"); + + ucmd.args(&["-cf", "archive.tar", "file.txt", "other.txt"]) + .succeeds(); + + // Without --wildcards, "*.txt" should match nothing (literal string) + new_ucmd!() + .args(&["-tf", "archive.tar", "*.txt"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_does_not_contain("file.txt") + .stdout_does_not_contain("other.txt"); +} + +// --strip-components tests + +#[test] +fn test_list_strip_components() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.write("dir/file.txt", "content"); + + ucmd.args(&["-cf", "archive.tar", "dir"]).succeeds(); + + new_ucmd!() + .args(&["-tf", "archive.tar", "--strip-components=1"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("file.txt") + .stdout_does_not_contain("dir/file.txt"); +} + +#[test] +fn test_extract_strip_components() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.write("dir/file.txt", "content"); + + ucmd.args(&["-cf", "archive.tar", "dir"]).succeeds(); + + at.remove("dir/file.txt"); + + new_ucmd!() + .args(&["-xf", "archive.tar", "--strip-components=1"]) + .current_dir(at.as_string()) + .succeeds(); + + assert!(at.file_exists("file.txt")); + assert_eq!(at.read("file.txt"), "content"); +} + +#[test] +fn test_extract_strip_components_skips_shallow_entries() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("shallow.txt", "shallow"); + at.mkdir("dir"); + at.write("dir/deep.txt", "deep"); + + ucmd.args(&["-cf", "archive.tar", "shallow.txt", "dir"]) + .succeeds(); + + at.remove("shallow.txt"); + at.remove("dir/deep.txt"); + + new_ucmd!() + .args(&["-xf", "archive.tar", "--strip-components=1"]) + .current_dir(at.as_string()) + .succeeds(); + + // shallow.txt has only one component; stripping 1 discards it + assert!(!at.file_exists("shallow.txt")); + assert!(at.file_exists("deep.txt")); +} + +#[test] +fn test_wildcards_and_strip_components_combined() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("dir"); + at.write("dir/keep.txt", "keep"); + at.write("dir/skip.rs", "skip"); + + ucmd.args(&["-cf", "archive.tar", "dir"]).succeeds(); + + new_ucmd!() + .args(&[ + "-tf", + "archive.tar", + "--wildcards", + "--strip-components=1", + "*.txt", + ]) + .current_dir(at.as_string()) + .succeeds() + .stdout_contains("keep.txt") + .stdout_does_not_contain("skip.rs"); +} From 1ec6bc72fe35246d3f63eb8743f488d64cdea260 Mon Sep 17 00:00:00 2001 From: Nicolas NOSAL Date: Tue, 30 Jun 2026 12:46:55 +0200 Subject: [PATCH 2/5] tar: update bench_tar.rs for new list_archive and extract_archive signatures list_archive and extract_archive now take three additional arguments (file_patterns, wildcards, strip_components). Pass neutral defaults (&[], false, 0) at the benchmark call sites so the benchmarks compile and measure the same workload as before. --- benches/bench_tar.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/benches/bench_tar.rs b/benches/bench_tar.rs index 638813c..06a0b8d 100644 --- a/benches/bench_tar.rs +++ b/benches/bench_tar.rs @@ -146,7 +146,7 @@ fn list_archive_50_files(bencher: divan::Bencher) { bencher.bench_local(|| { let input = File::open(&archive_path).unwrap(); - operations::list::list_archive(input, &archive_path, false, CompressionMode::None).unwrap(); + operations::list::list_archive(input, &archive_path, false, CompressionMode::None, &[], false, 0).unwrap(); }); } @@ -160,7 +160,7 @@ fn list_archive_verbose_50_files(bencher: divan::Bencher) { bencher.bench_local(|| { let input = File::open(&archive_path).unwrap(); - operations::list::list_archive(input, &archive_path, true, CompressionMode::None).unwrap(); + operations::list::list_archive(input, &archive_path, true, CompressionMode::None, &[], false, 0).unwrap(); }); } @@ -187,6 +187,9 @@ fn extract_archive_20_files(bencher: divan::Bencher) { &archive_path, false, CompressionMode::None, + &[], + false, + 0, ) .unwrap(); }); From e44483d509b156c212ccdda12a744a1019774c2f Mon Sep 17 00:00:00 2001 From: Nicolas NOSAL Date: Tue, 30 Jun 2026 13:07:20 +0200 Subject: [PATCH 3/5] tar: add tests for verbose+strip, nested parent dirs, and path traversal safety Cover the three uncovered code paths in extract_archive: - verbose output when strip_components > 0 (lines 76-78) - create_dir_all for non-empty parent after stripping (line 82) - path-traversal rejection for entries with ".." after strip (lines 68-74) --- src/uu/tar/src/operations/extract_tests.rs | 83 ++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/uu/tar/src/operations/extract_tests.rs b/src/uu/tar/src/operations/extract_tests.rs index 051e9f8..2d3d7e5 100644 --- a/src/uu/tar/src/operations/extract_tests.rs +++ b/src/uu/tar/src/operations/extract_tests.rs @@ -149,3 +149,86 @@ fn test_extract_strip_components_skips_shallow_entries() { assert!(!tempdir.path().join("root.txt").exists()); assert!(tempdir.path().join("deep.txt").exists()); } + +fn make_plain_tar(archive_path: &std::path::Path, entries: &[(&str, &str)]) { + let output = fs::File::create(archive_path).unwrap(); + let mut builder = tar::Builder::new(output); + for (name, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_mode(0o644); + header.set_size(content.len() as u64); + header.set_cksum(); + builder + .append_data(&mut header, name, std::io::Cursor::new(content.as_bytes())) + .unwrap(); + } + builder.finish().unwrap(); +} + +#[test] +fn test_extract_strip_components_verbose() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + make_zstd_tar(&archive_path, &[("prefix/file.txt", "content")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + true, + CompressionMode::Zstd, + &[], + false, + 1, + ) + .unwrap(); + + assert!(tempdir.path().join("file.txt").exists()); +} + +#[test] +fn test_extract_strip_creates_parent_dirs() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar.zst"); + // strip 1 → "subdir/file.txt"; parent "subdir" is non-empty → create_dir_all + make_zstd_tar(&archive_path, &[("prefix/subdir/file.txt", "content")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::Zstd, + &[], + false, + 1, + ) + .unwrap(); + + assert!(tempdir.path().join("subdir/file.txt").exists()); +} + +#[test] +fn test_extract_strip_rejects_path_traversal() { + let tempdir = tempdir().unwrap(); + let archive_path = tempdir.path().join("archive.tar"); + // "prefix/../../../escape.txt": after stripping "prefix", remaining path has ".." → rejected + make_plain_tar(&archive_path, &[("prefix/../../../escape.txt", "evil")]); + + let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); + let input = fs::File::open(&archive_path).unwrap(); + extract_archive( + input, + &archive_path, + false, + CompressionMode::None, + &[], + false, + 1, + ) + .unwrap(); + + assert!(!tempdir.path().join("escape.txt").exists()); +} From 090abe154679da45400df261278b89ceca5da7df Mon Sep 17 00:00:00 2001 From: Nicolas NOSAL Date: Tue, 30 Jun 2026 13:27:25 +0200 Subject: [PATCH 4/5] tar: fix rustfmt violations and path-traversal test - Reformat two long list_archive calls in bench_tar.rs to satisfy rustfmt - Replace make_plain_tar (which the tar crate rejects for paths with "..") with make_raw_traversal_tar that writes POSIX tar bytes directly, enabling tests for malicious archives with path-traversal components --- benches/bench_tar.rs | 22 +++++++++- src/uu/tar/src/operations/extract_tests.rs | 47 +++++++++++++++------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/benches/bench_tar.rs b/benches/bench_tar.rs index 06a0b8d..6d006aa 100644 --- a/benches/bench_tar.rs +++ b/benches/bench_tar.rs @@ -146,7 +146,16 @@ fn list_archive_50_files(bencher: divan::Bencher) { bencher.bench_local(|| { let input = File::open(&archive_path).unwrap(); - operations::list::list_archive(input, &archive_path, false, CompressionMode::None, &[], false, 0).unwrap(); + operations::list::list_archive( + input, + &archive_path, + false, + CompressionMode::None, + &[], + false, + 0, + ) + .unwrap(); }); } @@ -160,7 +169,16 @@ fn list_archive_verbose_50_files(bencher: divan::Bencher) { bencher.bench_local(|| { let input = File::open(&archive_path).unwrap(); - operations::list::list_archive(input, &archive_path, true, CompressionMode::None, &[], false, 0).unwrap(); + operations::list::list_archive( + input, + &archive_path, + true, + CompressionMode::None, + &[], + false, + 0, + ) + .unwrap(); }); } diff --git a/src/uu/tar/src/operations/extract_tests.rs b/src/uu/tar/src/operations/extract_tests.rs index 2d3d7e5..0ee61ea 100644 --- a/src/uu/tar/src/operations/extract_tests.rs +++ b/src/uu/tar/src/operations/extract_tests.rs @@ -150,19 +150,35 @@ fn test_extract_strip_components_skips_shallow_entries() { assert!(tempdir.path().join("deep.txt").exists()); } -fn make_plain_tar(archive_path: &std::path::Path, entries: &[(&str, &str)]) { - let output = fs::File::create(archive_path).unwrap(); - let mut builder = tar::Builder::new(output); - for (name, content) in entries { - let mut header = tar::Header::new_gnu(); - header.set_mode(0o644); - header.set_size(content.len() as u64); - header.set_cksum(); - builder - .append_data(&mut header, name, std::io::Cursor::new(content.as_bytes())) - .unwrap(); - } - builder.finish().unwrap(); +/// Write a minimal POSIX tar archive as raw bytes, bypassing the `tar` crate's path +/// validation. Real-world malicious archives can contain `..` or absolute path components +/// that the `tar` builder refuses to create — this helper lets us test those cases. +fn make_raw_traversal_tar(archive_path: &std::path::Path, entry_name: &[u8], content: &[u8]) { + use std::io::Write; + + let mut header = [0u8; 512]; + let n = entry_name.len().min(100); + header[..n].copy_from_slice(&entry_name[..n]); + header[100..108].copy_from_slice(b"0000644\0"); + header[108..116].copy_from_slice(b"0000000\0"); + header[116..124].copy_from_slice(b"0000000\0"); + let size_str = format!("{:011o}\0", content.len()); + header[124..136].copy_from_slice(size_str.as_bytes()); + header[136..148].copy_from_slice(b"00000000000\0"); + header[148..156].fill(b' '); // spaces for checksum calculation + header[156] = b'0'; // regular file + header[257..263].copy_from_slice(b"ustar\0"); + header[263..265].copy_from_slice(b"00"); + let checksum: u32 = header.iter().map(|&b| b as u32).sum(); + let cs = format!("{:06o}\0 ", checksum); + header[148..156].copy_from_slice(cs.as_bytes()); + + let pad = (512 - (content.len() % 512)) % 512; + let mut file = fs::File::create(archive_path).unwrap(); + file.write_all(&header).unwrap(); + file.write_all(content).unwrap(); + file.write_all(&vec![0u8; pad]).unwrap(); + file.write_all(&[0u8; 1024]).unwrap(); // end-of-archive } #[test] @@ -214,8 +230,9 @@ fn test_extract_strip_creates_parent_dirs() { fn test_extract_strip_rejects_path_traversal() { let tempdir = tempdir().unwrap(); let archive_path = tempdir.path().join("archive.tar"); - // "prefix/../../../escape.txt": after stripping "prefix", remaining path has ".." → rejected - make_plain_tar(&archive_path, &[("prefix/../../../escape.txt", "evil")]); + // After stripping "prefix", the remaining path "../../../escape.txt" contains ".." → skip. + // We write raw bytes to bypass the `tar` crate's builder validation (which also rejects ".."). + make_raw_traversal_tar(&archive_path, b"prefix/../../../escape.txt", b"evil"); let _guard = crate::operations::TestDirGuard::enter(tempdir.path()); let input = fs::File::open(&archive_path).unwrap(); From 9643717e1fc364be04169d223b3ae289517dce61 Mon Sep 17 00:00:00 2001 From: Nicolas NOSAL Date: Tue, 30 Jun 2026 12:07:32 +0200 Subject: [PATCH 5/5] tar: add -C/--directory flag Implements `-C DIR` / `--directory=DIR` (issue #169). The archive file is opened in the original working directory so that relative `-f` paths resolve correctly; `set_current_dir` is then called before the operation begins so that extraction, creation, and listing all happen relative to DIR. A private `apply_directory` helper avoids repeating the same `set_current_dir` + error-mapping block at each of the four call sites. A new `TarError::CannotChangeDirectory` variant carries the target path and underlying `io::Error` for a precise error message. Three integration tests cover the flag: basic extraction into a destination directory, combined use with `--strip-components`, and a check that a non-existent directory is rejected with exit code 2. --- src/uu/tar/src/errors.rs | 4 +++ src/uu/tar/src/tar.rs | 17 +++++++++++++ tests/by-util/test_tar.rs | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/src/uu/tar/src/errors.rs b/src/uu/tar/src/errors.rs index 7938beb..cb2ffed 100644 --- a/src/uu/tar/src/errors.rs +++ b/src/uu/tar/src/errors.rs @@ -66,6 +66,10 @@ pub enum TarError { /// Refusing to write archive contents to terminal #[error("tar: Refusing to write archive contents to terminal (missing -f option?)")] RefuseWriteArchiveToTerminal, + + /// Cannot change working directory + #[error("tar: Cannot change directory to '{path}': {source}")] + CannotChangeDirectory { path: PathBuf, source: io::Error }, } impl TarError { diff --git a/src/uu/tar/src/tar.rs b/src/uu/tar/src/tar.rs index be7e769..234fde0 100644 --- a/src/uu/tar/src/tar.rs +++ b/src/uu/tar/src/tar.rs @@ -150,6 +150,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { .get_one::("strip-components") .copied() .unwrap_or(0); + let directory = matches.get_one::("directory").cloned(); let explicit_compression = if matches.get_flag("gzip") { Some(CompressionMode::Gzip) } else if matches.get_flag("zstd") { @@ -171,6 +172,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { let compression = explicit_compression.unwrap_or(CompressionMode::Auto); return if archive_path == Path::new("-") { + apply_directory(&directory)?; operations::extract::extract_archive( io::stdin(), archive_path, @@ -183,6 +185,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; + apply_directory(&directory)?; operations::extract::extract_archive( file, archive_path, @@ -221,6 +224,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } else { let output = io::stdout().lock(); let status_output = io::stderr(); + apply_directory(&directory)?; operations::create::create_archive( output, status_output, @@ -235,6 +239,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { path: archive_path.clone(), source: e, })?; + apply_directory(&directory)?; let status_output = io::stdout().lock(); operations::create::create_archive( output, @@ -291,6 +296,16 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { )) } +fn apply_directory(dir: &Option) -> UResult<()> { + if let Some(d) = dir { + std::env::set_current_dir(d).map_err(|e| TarError::CannotChangeDirectory { + path: d.clone(), + source: e, + })?; + } + Ok(()) +} + #[allow(clippy::cognitive_complexity)] pub fn uu_app() -> Command { Command::new("tar (uutils)") @@ -325,6 +340,8 @@ pub fn uu_app() -> Command { arg!(-v --verbose "Verbosely list files processed"), // arg!(-h --dereference "Follow symlinks"), // arg!(-p --"preserve-permissions" "Extract information about file permissions"), + arg!(-C --directory "Change to directory DIR before performing any operation") + .value_parser(clap::value_parser!(PathBuf)), arg!(--wildcards "Use wildcards when matching file names"), arg!(--"strip-components" "Strip NUMBER leading components from file names") .value_parser(clap::value_parser!(u32)), diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index eacd9a1..c4e5286 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -1494,3 +1494,56 @@ fn test_wildcards_and_strip_components_combined() { .stdout_contains("keep.txt") .stdout_does_not_contain("skip.rs"); } + +#[test] +fn test_extract_with_directory_option() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file.txt", "hello"); + ucmd.args(&["-cf", "archive.tar", "file.txt"]).succeeds(); + at.remove("file.txt"); + + at.mkdir("dest"); + new_ucmd!() + .args(&["-xf", "archive.tar", "-C", "dest"]) + .current_dir(at.as_string()) + .succeeds(); + + assert!(at.file_exists("dest/file.txt")); + assert_eq!(at.read("dest/file.txt"), "hello"); + assert!(!at.file_exists("file.txt")); +} + +#[test] +fn test_extract_directory_option_with_strip_components() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.mkdir("src"); + at.write("src/file.txt", "content"); + ucmd.args(&["-cf", "archive.tar", "src"]).succeeds(); + at.remove("src/file.txt"); + + at.mkdir("dest"); + new_ucmd!() + .args(&["-xf", "archive.tar", "-C", "dest", "--strip-components=1"]) + .current_dir(at.as_string()) + .succeeds(); + + assert!(at.file_exists("dest/file.txt")); + assert!(!at.file_exists("dest/src/file.txt")); +} + +#[test] +fn test_extract_directory_option_nonexistent_dir_fails() { + let (at, mut ucmd) = at_and_ucmd!(); + + at.write("file.txt", "content"); + ucmd.args(&["-cf", "archive.tar", "file.txt"]).succeeds(); + + new_ucmd!() + .args(&["-xf", "archive.tar", "-C", "nonexistent"]) + .current_dir(at.as_string()) + .fails() + .code_is(2) + .stderr_contains("Cannot change directory"); +}