From c8b9de78994f591dc35ae659247c1d42347409b3 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:42:47 +0530 Subject: [PATCH] extract: restore directory modification times from the archive --- Cargo.lock | 1 + Cargo.toml | 1 + src/uu/tar/Cargo.toml | 1 + src/uu/tar/src/errors.rs | 4 +++ src/uu/tar/src/operations/extract.rs | 29 ++++++++++++++++++++- tests/by-util/test_tar.rs | 38 ++++++++++++++++++++++++++++ 6 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ff1e35b..54dcd4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1497,6 +1497,7 @@ version = "0.0.1" dependencies = [ "chrono", "clap", + "filetime", "flate2", "regex", "tar", diff --git a/Cargo.toml b/Cargo.toml index 7e84ee3..a59b371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ chrono = { version = "0.4.37", default-features = false, features = [ ] } clap = { version = "4.4", features = ["wrap_help", "cargo"] } ctor = "1.0.0" +filetime = "0.2" flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs"] } phf = "0.14.0" phf_codegen = "0.14.0" diff --git a/src/uu/tar/Cargo.toml b/src/uu/tar/Cargo.toml index beadc85..af35a41 100644 --- a/src/uu/tar/Cargo.toml +++ b/src/uu/tar/Cargo.toml @@ -14,6 +14,7 @@ categories = ["command-line-utilities"] [dependencies] chrono = { workspace = true } clap = { workspace = true } +filetime = { workspace = true } flate2 = { workspace = true } regex = { workspace = true } tar = { workspace = true } diff --git a/src/uu/tar/src/errors.rs b/src/uu/tar/src/errors.rs index adf771f..a39bf00 100644 --- a/src/uu/tar/src/errors.rs +++ b/src/uu/tar/src/errors.rs @@ -68,6 +68,10 @@ pub enum TarError { #[error("tar: Refusing to write archive contents to terminal (missing -f option?)")] RefuseWriteArchiveToTerminal, + /// The modification time of an extracted directory could not be set + #[error("tar: {path}: Cannot utime: {source}")] + CannotUtime { path: PathBuf, source: io::Error }, + /// A member name with a `..` component, which is not extracted #[error("tar: {path}: Member name contains '..'")] MemberNameContainsDotDot { path: PathBuf }, diff --git a/src/uu/tar/src/operations/extract.rs b/src/uu/tar/src/operations/extract.rs index cf465bf..4434216 100644 --- a/src/uu/tar/src/operations/extract.rs +++ b/src/uu/tar/src/operations/extract.rs @@ -6,6 +6,7 @@ use crate::compression::open_archive_reader; use crate::errors::{finish_after_previous_errors, report_recoverable, TarError}; use crate::CompressionMode; +use filetime::FileTime; use std::collections::HashSet; use std::io::Read; use std::io::{self, BufWriter, Write}; @@ -80,7 +81,17 @@ pub fn extract_archive( // Children before parents, so a read-only parent is restored last directories.sort_by(|a, b| b.0.cmp(&a.0)); for (path, mut entry) in directories { - refused_member |= unpack_entry(&mut entry, path, &mut out)?; + let mtime = entry.header().mtime().ok(); + let refused = unpack_entry(&mut entry, path.clone(), &mut out)?; + refused_member |= refused; + // The archive's mtime, as GNU tar restores it (tar-rs only does it for files) + if let (false, Some(mtime)) = (refused, mtime) { + if let Err(e) = set_directory_mtime(&path, mtime) { + out.flush().map_err(TarError::Io)?; + report_recoverable(&TarError::CannotUtime { path, source: e })?; + refused_member = true; + } + } } out.flush().map_err(TarError::Io)?; @@ -130,6 +141,22 @@ fn unpack_entry( Ok(!unpacked) } +/// Sets the mtime of the directory `unpack_in` extracted for the member `path`. +fn set_directory_mtime(path: &Path, mtime: u64) -> io::Result<()> { + // `unpack_in` drops the root, `.` and prefix components of member names + let dst: PathBuf = path + .components() + .filter(|c| matches!(c, Component::Normal(_))) + .collect(); + let dst = if dst.as_os_str().is_empty() { + PathBuf::from(".") + } else { + dst + }; + // Negative times come back from tar-rs as large u64 values; cast like it does + filetime::set_file_mtime(&dst, FileTime::from_unix_time(mtime as i64, 0)) +} + #[cfg(test)] #[path = "extract_tests.rs"] mod tests; diff --git a/tests/by-util/test_tar.rs b/tests/by-util/test_tar.rs index afbc074..3d823a4 100644 --- a/tests/by-util/test_tar.rs +++ b/tests/by-util/test_tar.rs @@ -659,6 +659,44 @@ fn test_extract_nested_read_only_directories() { assert_eq!(child & 0o777, 0o555); } +#[test] +fn test_extract_keeps_directory_mtime() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("out"); + + let mut tar_bytes = Vec::new(); + { + let mut builder = TarRsBuilder::new(&mut tar_bytes); + let mut header = TarRsHeader::new_gnu(); + header.set_entry_type(tar_rs_crate::EntryType::Directory); + header.set_mode(0o755); + header.set_mtime(1_000_000_000); + header.set_size(0); + builder + .append_data(&mut header, "dir/", std::io::empty()) + .unwrap(); + let mut header = TarRsHeader::new_gnu(); + header.set_mode(0o644); + header.set_mtime(1_000_000_000); + header.set_size(1); + builder + .append_data(&mut header, "dir/f", Cursor::new("x")) + .unwrap(); + builder.finish().unwrap(); + } + at.write_bytes("dir.tar", &tar_bytes); + + ucmd.args(&["-xf", "../dir.tar"]) + .current_dir(at.plus("out")) + .succeeds(); + + let mtime = at.metadata("out/dir").modified().unwrap(); + assert_eq!( + mtime, + std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000) + ); +} + #[test] fn test_extract_nonexistent_archive() { new_ucmd!()