Implement minimal create and extract operations (#35)

* Implement minimal create and extract operations

Adds basic tar archive functionality using tar-rs library with proper
error handling infrastructure.

Core Implementation:
- TarError enum implementing UError for uucore integration
- Extract operation (-x) for unpacking tar archives
- Create operation (-c) for creating tar archives from files/directories
- Verbose mode (-v) for both operations
- Proper CLI argument parsing with -f flag for archive file

Error Handling:
- Comprehensive error types (IoError, InvalidArchive, FileNotFound, etc.)
- Proper error conversion from io::Error
- UError trait implementation for exit code handling

This provides the minimal foundation for tar functionality. Both create
and extract are included together since they depend on each other for
testing purposes.

Includes 18 tests total:
- 8 integration tests (CLI basics, create/extract operations, error handling)
- 10 unit tests (CLI flag parsing, error type handling)
- Unit tests organized in src/uu/tar/tests/ separate from implementation

Note: Run `cargo test --workspace` to execute all tests including unit tests.

* Re-add cognitive complexity warning suppression for uu_app function

* Sort out tar/tarapp conflict

Rename package from "tar" to "tarapp" in Cargo.toml and update
dependencies to use workspace for tar.

* fix(errors): improve error handling and exit codes for tar operations

- Updated error messages for file not found and permission denied cases.
- Fix exit codes for various TarError types to conform to standard.
- Refactored extract_archive to use new error handling.
- Adjusted tests to reflect updated exit codes.

* Update for error changes (fixes cargo test --all)

* Use arg! macro and comment out rather than remove the unimplemented items.

* fix(create): Remove duplicate message for missing files in create_archive

---------

Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
This commit is contained in:
Jeff Bailey
2025-11-22 08:54:00 +00:00
committed by GitHub
parent bd288ef8c5
commit aa5fb1a952
14 changed files with 864 additions and 242 deletions
+1 -1
View File
@@ -102,5 +102,5 @@ jobs:
fault_type="${{ steps.vars.outputs.FAULT_TYPE }}"
fault_prefix=$(echo "$fault_type" | tr '[:lower:]' '[:upper:]')
# * convert any warnings to GHA UI annotations; ref: <https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-a-warning-message>
S=$(cargo clippy --all-targets -ptar -- ${CLIPPY_FLAGS} -D warnings 2>&1) && printf "%s\n" "$S" || { printf "%s\n" "$S" ; printf "%s" "$S" | sed -E -n -e '/^error:/{' -e "N; s/^error:[[:space:]]+(.*)\\n[[:space:]]+-->[[:space:]]+(.*):([0-9]+):([0-9]+).*$/::${fault_type} file=\2,line=\3,col=\4::${fault_prefix}: \`cargo clippy\`: \1 (file:'\2', line:\3)/p;" -e '}' ; fault=true ; }
S=$(cargo clippy --all-targets -ptarapp -- ${CLIPPY_FLAGS} -D warnings 2>&1) && printf "%s\n" "$S" || { printf "%s\n" "$S" ; printf "%s" "$S" | sed -E -n -e '/^error:/{' -e "N; s/^error:[[:space:]]+(.*)\\n[[:space:]]+-->[[:space:]]+(.*):([0-9]+):([0-9]+).*$/::${fault_type} file=\2,line=\3,col=\4::${fault_prefix}: \`cargo clippy\`: \1 (file:'\2', line:\3)/p;" -e '}' ; fault=true ; }
if [ -n "${{ steps.vars.outputs.FAIL_ON_FAULT }}" ] && [ -n "$fault" ]; then exit 1 ; fi
+23 -1
View File
@@ -1 +1,23 @@
/target
# spell-checker:ignore (misc) direnv
target/
coverage/
/src/*/gen_table
/build/
/tmp/
/busybox/
/.vscode/
/.vs/
/public/
*~
.*.swp
.*.swo
.idea
lib*.a
/docs/_build
*.iml
### macOS ###
.DS_Store
### direnv ###
/.direnv/
Generated
+298 -203
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -4,7 +4,7 @@
# spell-checker:ignore (libs) bigdecimal datetime fundu gethostid kqueue libselinux mangen memmap tar uuhelp
[package]
name = "tar"
name = "tarapp"
version = "0.0.1"
authors = ["uutils developers"]
license = "MIT"
@@ -46,6 +46,7 @@ regex = "1.10.4"
uucore = "0.4.0"
uuhelp_parser = "0.2.0"
uutests = "0.4.0"
tar = "0.4"
tempfile = "3.10.1"
textwrap = { version = "0.16.1", features = ["terminal_size"] }
xattr = "1.3.1"
@@ -71,6 +72,7 @@ libc = { workspace = true }
pretty_assertions = "1"
rand = { workspace = true }
regex = { workspace = true }
tar-rs-crate = { version = "0.4", package = "tar" }
tempfile = { workspace = true }
uucore = { workspace = true, features = ["entries", "process", "signals"] }
uutests = { workspace = true }
+8 -1
View File
@@ -7,5 +7,12 @@ use std::env;
use tar::uumain;
fn main() {
std::process::exit(uumain(env::args_os()));
let exit_code = uumain(env::args_os());
// If exiting with code 2, show the "not recoverable" message
if exit_code == 2 {
uucore::show_error!("Error is not recoverable: exiting now");
}
std::process::exit(exit_code);
}
+1
View File
@@ -16,6 +16,7 @@ categories = ["command-line-utilities"]
uucore = { workspace = true }
clap = { workspace = true }
regex = { workspace = true }
tar = { workspace = true }
[lib]
path = "src/tar.rs"
+85
View File
@@ -0,0 +1,85 @@
// 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 std::fmt;
use std::io;
use uucore::error::UError;
/// Error types for tar operations
#[derive(Debug)]
pub enum TarError {
/// I/O error occurred
IoError(io::Error),
/// Invalid archive format or corrupted archive
InvalidArchive(String),
/// File or directory not found
FileNotFound(String),
/// Permission denied
PermissionDenied(String),
/// General tar operation error
TarOperationError(String),
}
/// Implements display formatting for TarError.
impl fmt::Display for TarError {
/// Formats the error for display to users
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
TarError::IoError(err) => write!(f, "{err}"),
TarError::InvalidArchive(msg) => write!(f, "{msg}"),
TarError::FileNotFound(path) => {
write!(f, "{path}: Cannot open: No such file or directory")
}
TarError::PermissionDenied(path) => {
write!(f, "{path}: Cannot open: Permission denied")
}
TarError::TarOperationError(msg) => write!(f, "{msg}"),
}
}
}
impl TarError {
/// Create a TarError from an io::Error with file path context
pub fn from_io_error(err: io::Error, path: &std::path::Path) -> Self {
match err.kind() {
io::ErrorKind::NotFound => TarError::FileNotFound(path.display().to_string()),
io::ErrorKind::PermissionDenied => {
TarError::PermissionDenied(path.display().to_string())
}
_ => TarError::IoError(err),
}
}
}
impl std::error::Error for TarError {
/// Returns the underlying error cause, if any
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
TarError::IoError(err) => Some(err),
_ => None,
}
}
}
impl UError for TarError {
/// Returns the exit code for this error type
fn code(&self) -> i32 {
match self {
TarError::IoError(_) => 2,
TarError::InvalidArchive(_) => 2,
TarError::FileNotFound(_) => 2,
TarError::PermissionDenied(_) => 2,
TarError::TarOperationError(_) => 2,
}
}
}
impl From<io::Error> for TarError {
/// Converts io::Error into the appropriate TarError variant
fn from(err: io::Error) -> Self {
// For generic io::Error without context, just wrap it
TarError::IoError(err)
}
}
+81
View File
@@ -0,0 +1,81 @@
// 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 std::fs::File;
use std::path::Path;
use tar::Builder;
use uucore::error::UResult;
/// Create a tar archive from the specified files
///
/// # Arguments
///
/// * `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
///
/// # Errors
///
/// Returns an error if:
/// - 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);
if verbose {
println!("Creating archive: {}", archive_path.display());
}
// Add each file or directory to the archive
for &path in files {
if verbose {
println!("{}", path.display());
}
// Check if path exists
if !path.exists() {
return Err(TarError::FileNotFound(path.display().to_string()).into());
}
// If it's a directory, recursively add all contents
if path.is_dir() {
builder.append_dir_all(path, path).map_err(|e| {
TarError::TarOperationError(format!(
"Failed to add directory '{}': {}",
path.display(),
e
))
})?;
} else {
// For files, add them directly
builder.append_path(path).map_err(|e| {
TarError::TarOperationError(format!(
"Failed to add file '{}': {}",
path.display(),
e
))
})?;
}
}
// Finish writing the archive
builder
.finish()
.map_err(|e| TarError::TarOperationError(format!("Failed to finalize archive: {e}")))?;
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
// 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 std::fs::File;
use std::path::Path;
use tar::Archive;
use uucore::error::UResult;
/// Extract files from a tar archive
///
/// # Arguments
///
/// * `archive_path` - Path to the tar archive to extract
/// * `verbose` - Whether to print verbose output during extraction
///
/// # 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
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);
// Extract to current directory
if verbose {
println!("Extracting archive: {}", archive_path.display());
}
// Iterate through entries for verbose output and error handling
for entry_result in archive
.entries()
.map_err(|e| TarError::InvalidArchive(format!("Failed to read archive entries: {e}")))?
{
let mut entry = entry_result
.map_err(|e| TarError::InvalidArchive(format!("Failed to read entry: {e}")))?;
// Get the path before unpacking (clone it so we can use it after borrowing entry mutably)
let path = entry
.path()
.map_err(|e| TarError::InvalidArchive(format!("Failed to read entry path: {e}")))?
.to_path_buf();
if verbose {
println!("{}", path.display());
}
// Unpack the entry
entry.unpack_in(".").map_err(|e| {
TarError::TarOperationError(format!("Failed to extract '{}': {}", path.display(), e))
})?;
}
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
// 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.
pub mod create;
pub mod extract;
+71 -31
View File
@@ -3,29 +3,73 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use clap::{arg, crate_version, Arg, ArgAction, Command};
use std::path::PathBuf;
pub mod errors;
mod operations;
use clap::{arg, crate_version, ArgAction, Command};
use std::path::{Path, PathBuf};
use uucore::error::UResult;
use uucore::format_usage;
const ABOUT: &str = "an archiving utility";
const USAGE: &str = "tar {A|c|d|r|t|u|x}[GnSkUWOmpsMBiajJzZhPlRvwo] [ARG...]";
const USAGE: &str = "tar {c|x}[v] -f ARCHIVE [FILE...]";
#[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let matches = uu_app().try_get_matches_from(args)?;
// Collect args - the test framework may add util_name as args[1], so skip it if present
let args_vec: Vec<_> = args.collect();
let util_name = uucore::util_name();
// For now, just print a basic message indicating the command was parsed
println!("tar: basic implementation - command line parsed successfully");
// Skip duplicate util name if present (can be "tar" or "tarapp")
let args_to_parse = if args_vec.len() > 1
&& (args_vec[1] == util_name || args_vec[1] == "tar" || args_vec[1] == "tarapp")
{
let mut result = vec![args_vec[0].clone()];
result.extend_from_slice(&args_vec[2..]);
result
} else {
args_vec
};
// Check if any files were specified
if let Some(files) = matches.get_many::<PathBuf>("file") {
for file in files {
println!("File: {}", file.display());
}
let matches = uu_app().try_get_matches_from(args_to_parse)?;
let verbose = matches.get_flag("verbose");
// Handle extract operation
if matches.get_flag("extract") {
let archive_path = matches.get_one::<PathBuf>("file").ok_or_else(|| {
uucore::error::USimpleError::new(1, "option requires an argument -- 'f'")
})?;
return operations::extract::extract_archive(archive_path, verbose);
}
Ok(())
// Handle create operation
if matches.get_flag("create") {
let archive_path = matches.get_one::<PathBuf>("file").ok_or_else(|| {
uucore::error::USimpleError::new(1, "option requires an argument -- 'f'")
})?;
let files: Vec<&Path> = matches
.get_many::<PathBuf>("files")
.map(|v| v.map(|p| p.as_path()).collect())
.unwrap_or_default();
if files.is_empty() {
return Err(uucore::error::USimpleError::new(
1,
"Cowardly refusing to create an empty archive",
));
}
return operations::create::create_archive(archive_path, &files, verbose);
}
// If no operation specified, show error
Err(uucore::error::USimpleError::new(
1,
"You must specify one of the '-c' or '-x' options",
))
}
#[allow(clippy::cognitive_complexity)]
@@ -38,33 +82,29 @@ pub fn uu_app() -> Command {
.disable_help_flag(true)
.args([
// Main operation modes
arg!(-A --catenate "Append tar files to archive"),
arg!(-c --create "Create a new archive"),
arg!(-d --diff "Find differences between archive and file system").alias("compare"),
arg!(-r --append "Append files to end of archive"),
arg!(-t --list "List contents of archive"),
arg!(-u --update "Only append files newer than copy in archive"),
// arg!(-d --diff "Find differences between archive and file system").alias("compare"),
// arg!(-r --append "Append files to end of archive"),
// arg!(-t --list "List contents of archive"),
// arg!(-u --update "Only append files newer than copy in archive"),
arg!(-x --extract "Extract files from archive").alias("get"),
// Archive file
arg!(-f --file <ARCHIVE> "Use archive file").value_parser(clap::value_parser!(PathBuf)),
arg!(-f --file <ARCHIVE> "Use archive file or device ARCHIVE")
.value_parser(clap::value_parser!(PathBuf)),
// Compression options
arg!(-z --gzip "Filter through gzip"),
arg!(-j --bzip2 "Filter through bzip2"),
arg!(-J --xz "Filter through xz"),
// arg!(-z --gzip "Filter through gzip"),
// arg!(-j --bzip2 "Filter through bzip2"),
// arg!(-J --xz "Filter through xz"),
// Common options
arg!(-v --verbose "Verbosely list files processed"),
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"),
// 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"),
// Help
arg!(--help "Print help information").action(ArgAction::Help),
// Files to process
Arg::new("files")
.help("Files to archive or extract")
.value_parser(clap::value_parser!(PathBuf))
.num_args(0..),
arg!([files]... "Files to archive or extract")
.action(ArgAction::Append)
.value_parser(clap::value_parser!(PathBuf)),
])
}
#[cfg(test)]
mod tests {}
+34
View File
@@ -0,0 +1,34 @@
// 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 uu_tar::uu_app;
#[test]
fn test_extract_flag_parsing() {
let app = uu_app();
let result = app.try_get_matches_from(vec!["tar", "-xf", "archive.tar"]);
assert!(result.is_ok());
let matches = result.unwrap();
assert!(matches.get_flag("extract"));
}
#[test]
fn test_create_flag_parsing() {
let app = uu_app();
let result = app.try_get_matches_from(vec!["tar", "-cf", "archive.tar", "file.txt"]);
assert!(result.is_ok());
let matches = result.unwrap();
assert!(matches.get_flag("create"));
}
#[test]
fn test_verbose_flag_parsing() {
let app = uu_app();
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!(matches.get_flag("create"));
}
+92
View File
@@ -0,0 +1,92 @@
// 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 std::io;
use uu_tar::errors::TarError;
#[test]
fn test_tar_error_display() {
let err = TarError::FileNotFound("test.txt".to_string());
assert_eq!(
err.to_string(),
"test.txt: Cannot open: No such file or directory"
);
let err = TarError::InvalidArchive("corrupted header".to_string());
assert_eq!(err.to_string(), "corrupted header");
let err = TarError::PermissionDenied("/root/file".to_string());
assert_eq!(
err.to_string(),
"/root/file: Cannot open: Permission denied"
);
let err = TarError::TarOperationError("failed to write".to_string());
assert_eq!(err.to_string(), "failed to write");
}
#[test]
fn test_tar_error_code() {
use uucore::error::UError;
assert_eq!(TarError::FileNotFound("test".to_string()).code(), 2);
assert_eq!(TarError::InvalidArchive("test".to_string()).code(), 2);
assert_eq!(TarError::PermissionDenied("test".to_string()).code(), 2);
assert_eq!(TarError::TarOperationError("test".to_string()).code(), 2);
}
#[test]
fn test_io_error_conversion_not_found() {
let io_err = io::Error::new(io::ErrorKind::NotFound, "file not found");
let tar_err = TarError::from(io_err);
match tar_err {
TarError::IoError(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
_ => panic!("Expected IoError variant"),
}
}
#[test]
fn test_io_error_conversion_permission_denied() {
let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
let tar_err = TarError::from(io_err);
match tar_err {
TarError::IoError(e) => assert_eq!(e.kind(), io::ErrorKind::PermissionDenied),
_ => panic!("Expected IoError variant"),
}
}
#[test]
fn test_io_error_conversion_other() {
let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "pipe broken");
let tar_err = TarError::from(io_err);
match tar_err {
TarError::IoError(e) => assert_eq!(e.kind(), io::ErrorKind::BrokenPipe),
_ => panic!("Expected IoError variant"),
}
}
#[test]
fn test_error_source() {
let io_err = io::Error::other("some error");
let tar_err = TarError::IoError(io_err);
// IoError should have a source
assert!(std::error::Error::source(&tar_err).is_some());
// Other variants should not have a source
let tar_err = TarError::FileNotFound("test".to_string());
assert!(std::error::Error::source(&tar_err).is_none());
}
#[test]
fn test_tar_error_is_debug() {
let err = TarError::TarOperationError("test".to_string());
let debug_str = format!("{err:?}");
assert!(debug_str.contains("TarOperationError"));
assert!(debug_str.contains("test"));
}
+98 -4
View File
@@ -3,9 +3,10 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use uutests::new_ucmd;
use uutests::{at_and_ucmd, new_ucmd};
// Basic CLI Tests
// Basic tar tests
#[test]
fn test_invalid_arg() {
new_ucmd!().arg("--definitely-invalid").fails().code_is(1);
@@ -13,10 +14,103 @@ fn test_invalid_arg() {
#[test]
fn test_help() {
new_ucmd!().arg("--help").succeeds().code_is(0);
new_ucmd!()
.arg("--help")
.succeeds()
.code_is(0)
.stdout_contains("an archiving utility");
}
#[test]
fn test_version() {
new_ucmd!().arg("--version").succeeds().code_is(0);
new_ucmd!()
.arg("--version")
.succeeds()
.code_is(0)
.stdout_contains("tar");
}
// Create operation tests
#[test]
fn test_create_single_file() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1.txt", "test content");
ucmd.args(&["-cf", "archive.tar", "file1.txt"]).succeeds();
assert!(at.file_exists("archive.tar"));
}
#[test]
fn test_create_multiple_files() {
let (at, mut ucmd) = at_and_ucmd!();
at.write("file1.txt", "content1");
at.write("file2.txt", "content2");
ucmd.args(&["-cf", "archive.tar", "file1.txt", "file2.txt"])
.succeeds();
assert!(at.file_exists("archive.tar"));
}
// Extract operation tests
#[test]
fn test_extract_single_file() {
let (at, mut ucmd) = at_and_ucmd!();
// Create an archive first
at.write("original.txt", "test content");
ucmd.args(&["-cf", "archive.tar", "original.txt"])
.succeeds();
// Remove original and extract
at.remove("original.txt");
new_ucmd!()
.arg("-xf")
.arg(at.plus("archive.tar"))
.current_dir(at.as_string())
.succeeds();
assert!(at.file_exists("original.txt"));
assert_eq!(at.read("original.txt"), "test content");
}
#[test]
fn test_extract_multiple_files() {
let (at, mut ucmd) = at_and_ucmd!();
// Create an archive with multiple files
at.write("file1.txt", "content1");
at.write("file2.txt", "content2");
ucmd.args(&["-cf", "archive.tar", "file1.txt", "file2.txt"])
.succeeds();
// Remove originals
at.remove("file1.txt");
at.remove("file2.txt");
// Extract
new_ucmd!()
.arg("-xf")
.arg(at.plus("archive.tar"))
.current_dir(at.as_string())
.succeeds();
assert!(at.file_exists("file1.txt"));
assert!(at.file_exists("file2.txt"));
assert_eq!(at.read("file1.txt"), "content1");
assert_eq!(at.read("file2.txt"), "content2");
}
#[test]
fn test_extract_nonexistent_archive() {
new_ucmd!()
.args(&["-xf", "nonexistent.tar"])
.fails()
.code_is(2);
}