diff --git a/src/uu/tar/src/display.rs b/src/uu/tar/src/display.rs new file mode 100644 index 0000000..b9349be --- /dev/null +++ b/src/uu/tar/src/display.rs @@ -0,0 +1,67 @@ +// 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 chrono::{TimeZone, Utc}; +use std::io::Write; +use std::path::Path; +use uucore::fs::display_permissions_unix; + +/// Print a verbose (ls -l style) line for an entry in a tar archive +pub fn print_entry_verbose( + mut out: W, + header: &tar::Header, + path: &Path, +) -> std::io::Result<()> { + let mode = header.mode().unwrap_or(0); + let entry_type = header.entry_type(); + let owner = header + .username() + .ok() + .flatten() + .map(|s| s.to_owned()) + .unwrap_or_else(|| header.uid().unwrap_or(0).to_string()); + let group = header + .groupname() + .ok() + .flatten() + .map(|s| s.to_owned()) + .unwrap_or_else(|| header.gid().unwrap_or(0).to_string()); + let size = header.size().unwrap_or(0); + let mtime = header.mtime().unwrap_or(0); + + let type_char = match entry_type { + tar::EntryType::Directory => 'd', + tar::EntryType::Symlink => 'l', + tar::EntryType::Char => 'c', + tar::EntryType::Block => 'b', + tar::EntryType::Fifo => 'p', + _ => '-', + }; + // Tar headers store the type separately from the mode bits, so we get the + // 9-character rwx string from uucore and prepend our own type character. + let perm_str = display_permissions_unix(mode, false); + let permissions = format!("{type_char}{perm_str}"); + + // TODO: GNU tar displays mtime in the user's local timezone; we + // currently format in UTC. Convert to local time for compatibility. + let dt: chrono::DateTime = Utc + .timestamp_opt(mtime as i64, 0) + .single() + .unwrap_or_else(Utc::now); + let date_str = dt.format("%Y-%m-%d %H:%M"); + + // TODO: use path.has_trailing_sep() when stable + let path_str = path.display().to_string(); + let suffix = if entry_type.is_dir() && !path_str.ends_with("/") { + std::path::MAIN_SEPARATOR_STR + } else { + "" + }; + + writeln!( + out, + "{permissions} {owner}/{group} {size:>8} {date_str} {path_str}{suffix}" + ) +} diff --git a/src/uu/tar/src/operations/create.rs b/src/uu/tar/src/operations/create.rs index a33d801..debcb55 100644 --- a/src/uu/tar/src/operations/create.rs +++ b/src/uu/tar/src/operations/create.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use crate::display; use crate::errors::TarError; use std::collections::VecDeque; use std::fs::{self, File}; @@ -18,7 +19,7 @@ use uucore::error::UResult; /// /// * `archive_path` - Path where the tar archive should be created /// * `files` - Slice of file paths to add to the archive -/// * `verbose` - Whether to print verbose output during creation +/// * `verbose` - Verbosity level during creation /// /// # Errors /// @@ -26,7 +27,7 @@ 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<()> { +pub fn create_archive(archive_path: &Path, files: &[&Path], verbose: u8) -> UResult<()> { // Create the output file let file = File::create(archive_path).map_err(|e| TarError::CannotCreateArchive { path: archive_path.to_path_buf(), @@ -47,7 +48,17 @@ pub fn create_archive(archive_path: &Path, files: &[&Path], verbose: bool) -> UR .into()); } - if verbose { + if verbose >= 2 { + for p in get_tree(path)? { + let metadata = p.metadata().map_err(|e| TarError::CannotAddFile { + path: p.clone(), + source: e, + })?; + let mut header = tar::Header::new_gnu(); + header.set_metadata(&metadata); + display::print_entry_verbose(&mut out, &header, &p).map_err(TarError::Io)?; + } + } else if verbose == 1 { let to_print = get_tree(path)? .iter() .map(|p| (p.is_dir(), p.display().to_string())) diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index 135b66f..8932dae 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use crate::display; use crate::errors::TarError; use std::fs::File; use std::io::{self, BufWriter, Write}; @@ -15,7 +16,7 @@ use uucore::error::UResult; /// # Arguments /// /// * `archive_path` - Path to the tar archive to extract -/// * `verbose` - Whether to print verbose output during extraction +/// * `verbose` - Verbosity level during extraction /// /// # Errors /// @@ -23,7 +24,7 @@ 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<()> { +pub fn extract_archive(archive_path: &Path, verbose: u8) -> UResult<()> { // Open the archive file let file = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; @@ -32,7 +33,7 @@ pub fn extract_archive(archive_path: &Path, verbose: bool) -> UResult<()> { let mut out = BufWriter::new(io::stdout().lock()); // Extract to current directory - if verbose { + if verbose >= 1 { writeln!(out, "Extracting archive: {}", archive_path.display()).map_err(TarError::Io)?; } @@ -46,7 +47,9 @@ pub fn extract_archive(archive_path: &Path, verbose: bool) -> UResult<()> { .map_err(TarError::CannotReadEntryPath)? .to_path_buf(); - if verbose { + if verbose >= 2 { + display::print_entry_verbose(&mut out, entry.header(), &path).map_err(TarError::Io)?; + } else if verbose == 1 { writeln!(out, "{}", path.display()).map_err(TarError::Io)?; } diff --git a/src/uu/tar/src/operations/list.rs b/src/uu/tar/src/operations/list.rs index c424ca8..5174bb2 100644 --- a/src/uu/tar/src/operations/list.rs +++ b/src/uu/tar/src/operations/list.rs @@ -3,17 +3,16 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use crate::display; use crate::errors::TarError; -use chrono::{TimeZone, Utc}; use std::fs::File; use std::io::{self, BufWriter, Write}; 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<()> { +pub fn list_archive(archive_path: &Path, verbose: u8) -> UResult<()> { let file: File = File::open(archive_path).map_err(|e| TarError::from_io_error(e, archive_path))?; let mut archive = Archive::new(file); @@ -21,64 +20,11 @@ pub fn list_archive(archive_path: &Path, verbose: bool) -> UResult<()> { 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)?; - if verbose { - // Collect all header fields into owned values before borrowing entry for the path, - // since both header() and path() require a borrow of entry. - let (mode, entry_type, owner, group, size, mtime) = { - let header = entry.header(); - ( - header.mode().unwrap_or(0), - header.entry_type(), - header - .username() - .ok() - .flatten() - .unwrap_or_default() - .to_owned(), - header - .groupname() - .ok() - .flatten() - .unwrap_or_default() - .to_owned(), - header.size().unwrap_or(0), - header.mtime().unwrap_or(0), - ) - }; - - let path = entry.path().map_err(TarError::CannotReadEntryPath)?; - - let type_char = match entry_type { - tar::EntryType::Directory => 'd', - tar::EntryType::Symlink => 'l', - tar::EntryType::Char => 'c', - tar::EntryType::Block => 'b', - tar::EntryType::Fifo => 'p', - _ => '-', - }; - // Tar headers store the type separately from the mode bits, so we get the - // 9-character rwx string from uucore and prepend our own type character. - let perm_str = display_permissions_unix(mode, false); - let permissions = format!("{type_char}{perm_str}"); - - // TODO: GNU tar displays mtime in the user's local timezone; we - // currently format in UTC. Convert to local time for compatibility. - let dt: chrono::DateTime = Utc - .timestamp_opt(mtime as i64, 0) - .single() - .unwrap_or_else(Utc::now); - let date_str = dt.format("%Y-%m-%d %H:%M"); - - writeln!( - out, - "{permissions} {owner}/{group} {size:>8} {date_str} {}", - path.display() - ) - .map_err(TarError::Io)?; + if verbose >= 1 { + display::print_entry_verbose(&mut out, entry.header(), &path).map_err(TarError::Io)?; } else { - let path = entry.path().map_err(TarError::CannotReadEntryPath)?; - writeln!(out, "{}", path.display()).map_err(TarError::Io)?; } } diff --git a/src/uu/tar/src/tar.rs b/src/uu/tar/src/tar.rs index 16e29d1..f3170e1 100644 --- a/src/uu/tar/src/tar.rs +++ b/src/uu/tar/src/tar.rs @@ -3,6 +3,7 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +mod display; pub mod errors; mod operations; @@ -130,7 +131,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }; - let verbose = matches.get_flag("verbose"); + let verbose = matches.get_count("verbose"); // Handle extract operation if matches.get_flag("extract") { @@ -204,7 +205,7 @@ pub fn uu_app() -> Command { // arg!(-j --bzip2 "Filter through bzip2"), // arg!(-J --xz "Filter through xz"), // Common options - arg!(-v --verbose "Verbosely list files processed"), + arg!(-v --verbose "Verbosely list files processed").action(ArgAction::Count), // arg!(-h --dereference "Follow symlinks"), // arg!(-p --"preserve-permissions" "Extract information about file permissions"), // arg!(-P --"absolute-names" "Don't strip leading '/' from file names"), @@ -285,6 +286,13 @@ mod tests { assert_eq!(expand_posix_keystring(input), expected); } + #[test] + fn test_expand_cvvf() { + let input = osvec(&["tar", "cvvf", "archive.tar", "file.txt"]); + let expected = osvec(&["tar", "-c", "-v", "-v", "-f", "archive.tar", "file.txt"]); + assert_eq!(expand_posix_keystring(input), expected); + } + #[test] fn test_expand_xf() { let input = osvec(&["tar", "xf", "archive.tar"]); diff --git a/src/uu/tar/tests/test_cli.rs b/src/uu/tar/tests/test_cli.rs index 9b42cd7..e2de4e3 100644 --- a/src/uu/tar/tests/test_cli.rs +++ b/src/uu/tar/tests/test_cli.rs @@ -29,6 +29,16 @@ fn test_verbose_flag_parsing() { let result = app.try_get_matches_from(vec!["tar", "-cvf", "archive.tar", "file.txt"]); assert!(result.is_ok()); let matches = result.unwrap(); - assert!(matches.get_flag("verbose")); + assert_eq!(matches.get_count("verbose"), 1); + assert!(matches.get_flag("create")); +} + +#[test] +fn test_double_verbose_flag_parsing() { + let app = uu_app(); + let result = app.try_get_matches_from(vec!["tar", "-cvvf", "archive.tar", "file.txt"]); + assert!(result.is_ok()); + let matches = result.unwrap(); + assert_eq!(matches.get_count("verbose"), 2); assert!(matches.get_flag("create")); } diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index 6b8e506..e1b8600 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -5,6 +5,7 @@ use std::path::{self, PathBuf}; +use regex::Regex; use uutests::{at_and_ucmd, new_ucmd}; /// Size of a single tar block in bytes (per POSIX specification). @@ -145,6 +146,20 @@ fn test_create_verbose() { assert!(at.file_exists("archive.tar")); } +#[test] +fn test_create_double_verbose() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("file.txt", "content"); + at.mkdir("dir"); + + let file_regex = Regex::new(r"-.{9} .* 7 \d{4}-\d{2}-\d{2} \d{2}:\d{2} file.txt\r?\n").unwrap(); + let dir_regex = Regex::new(r"d.{9} .* 0 \d{4}-\d{2}-\d{2} \d{2}:\d{2} dir[/\\]+\r?\n").unwrap(); + ucmd.args(&["-cvvf", "archive.tar", "file.txt", "dir"]) + .succeeds() + .stdout_matches(&file_regex) + .stdout_matches(&dir_regex); +} + #[test] fn test_create_empty_archive_fails() { new_ucmd!() @@ -239,6 +254,26 @@ fn test_extract_verbose() { assert!(at.file_exists("file3.txt")); } +#[test] +fn test_extract_double_verbose() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("file.txt", "content"); + at.mkdir("dir"); + ucmd.args(&["-cf", "archive.tar", "file.txt", "dir"]) + .succeeds(); + at.remove("file.txt"); + at.rmdir("dir"); + + let file_regex = Regex::new(r"-.{9} .* 7 \d{4}-\d{2}-\d{2} \d{2}:\d{2} file.txt\r?\n").unwrap(); + let dir_regex = Regex::new(r"d.{9} .* 0 \d{4}-\d{2}-\d{2} \d{2}:\d{2} dir[/\\]+\r?\n").unwrap(); + new_ucmd!() + .args(&["-xvvf", "archive.tar"]) + .current_dir(at.as_string()) + .succeeds() + .stdout_matches(&file_regex) + .stdout_matches(&dir_regex); +} + #[test] fn test_extract_multiple_files() { let (at, mut ucmd) = at_and_ucmd!();