diff --git a/src/cli/verbs/cp.rs b/src/cli/verbs/cp.rs index 201270d3..2c95ea9f 100644 --- a/src/cli/verbs/cp.rs +++ b/src/cli/verbs/cp.rs @@ -182,6 +182,7 @@ pub fn run(args: CpArgs) -> Result<()> { AttrPolicyArg::Skip => AttrPolicy::Skip, }, flatten: args.flatten, + preserve_times: false, }; let case_insensitive = !args.case_sensitive; // default-insensitive, like get diff --git a/src/fs/attrs.rs b/src/fs/attrs.rs index 177cd8af..c6bfec6e 100644 --- a/src/fs/attrs.rs +++ b/src/fs/attrs.rs @@ -79,6 +79,11 @@ impl AttrOverrides { pub fn is_empty(&self) -> bool { self.mode.is_none() && self.uid.is_none() && self.gid.is_none() && self.unix_times.is_none() } + + /// True when a mode or owner was specified; a date alone is not a permission. + pub fn has_permissions(&self) -> bool { + self.mode.is_some() || self.uid.is_some() || self.gid.is_some() + } } /// The decision, with provenance per field. diff --git a/src/fs/copy.rs b/src/fs/copy.rs index eeaf1e28..a119813b 100644 --- a/src/fs/copy.rs +++ b/src/fs/copy.rs @@ -94,6 +94,9 @@ pub struct CopyOptions { /// CBM, Apple/Atari DOS, MFS, …). Without it, copying a tree into a /// flat filesystem is a hard error. pub flatten: bool, + /// Carry each source entry's modification date onto the copy (archive expansion). Off by + /// default, which keeps `cp` / Commander stamping the time of the copy. + pub preserve_times: bool, } impl Default for CopyOptions { @@ -104,6 +107,7 @@ impl Default for CopyOptions { names: NamePolicy::Truncate, attrs: AttrPolicy::Preserve, flatten: false, + preserve_times: false, } } } @@ -357,7 +361,11 @@ fn copy_recursive( Some(n) => n, None => return Ok(()), }; - let dir_entry = ensure_directory(src_caps, src_entry, dst, dst_parent, &name)?; + let times = opts + .preserve_times + .then(|| source_times(src_entry)) + .flatten(); + let dir_entry = ensure_directory(src_caps, src_entry, dst, dst_parent, &name, times)?; stats.dirs += 1; for child in list_children(src, src_entry)? { copy_recursive( @@ -447,6 +455,9 @@ fn copy_file( // Translate whatever metadata the destination can carry. let (mut options, mut dropped) = translate_metadata(src_entry, src_caps, dst_caps, opts.attrs); + if opts.preserve_times { + options.unix_times = source_times(src_entry); + } // Resource fork is data, not an attribute: copy it whenever the // destination supports forks, regardless of AttrPolicy. @@ -516,6 +527,16 @@ fn copy_file( Ok(()) } +/// An entry's modification date as Unix time: its own Unix mtime, else its classic-Mac date, +/// which (as on HFS) is local wall-clock time. +fn source_times(e: &FileEntry) -> Option { + let secs = e.modified_unix.or_else(|| { + e.mac_dates + .and_then(|(_, modified, _)| crate::fs::times::mac_local_to_unix(modified)) + })?; + Some(crate::fs::times::UnixTimes::mtime_only(secs)) +} + /// Build the [`CreateFileOptions`] carrying every metadata axis the /// destination supports, recording which axes had to be dropped. fn translate_metadata( @@ -620,6 +641,7 @@ fn ensure_directory( dst: &mut dyn EditableFilesystem, parent: &FileEntry, name: &str, + times: Option, ) -> Result { if let Some(existing) = find_child(dst, parent, name)? { if existing.is_directory() { @@ -627,7 +649,10 @@ fn ensure_directory( } bail!("cannot create directory {name}: a file of that name already exists"); } - let mut opts = CreateDirectoryOptions::default(); + let mut opts = CreateDirectoryOptions { + unix_times: times, + ..Default::default() + }; let dst_caps = Capabilities::infer(dst.fs_type()); if src_caps.unix_permissions && dst_caps.unix_permissions { opts.mode = src_dir.mode; diff --git a/src/fs/dir_import.rs b/src/fs/dir_import.rs index 40f89242..6332b0d0 100644 --- a/src/fs/dir_import.rs +++ b/src/fs/dir_import.rs @@ -106,10 +106,20 @@ fn gunzip_target(host: &Path, file_name: &str) -> Option { /// Decompressed length of a (possibly multi-member) gzip file. The trailer's /// ISIZE is only the length mod 4 GiB, so the stream is counted instead. fn gunzipped_len(path: &Path) -> Result { + Ok(gunzip_scan(path)?.0) +} + +/// Decompressed length plus the original file's mtime from the gzip header (0 means unset). +fn gunzip_scan(path: &Path) -> Result<(u64, Option)> { let f = File::open(path).with_context(|| format!("opening {}", path.display()))?; let mut dec = flate2::read::MultiGzDecoder::new(std::io::BufReader::new(f)); - std::io::copy(&mut dec, &mut std::io::sink()) - .with_context(|| format!("decompressing {}", path.display())) + let len = std::io::copy(&mut dec, &mut std::io::sink()) + .with_context(|| format!("decompressing {}", path.display()))?; + let mtime = dec + .header() + .map(|h| u64::from(h.mtime())) + .filter(|t| *t != 0); + Ok((len, mtime)) } /// Which expander handles a host file under `expand_archives`. @@ -146,6 +156,8 @@ fn measure_expanded(flavor: ArchiveFlavor, path: &Path) -> Result<(u64, u64, u64 fn expanded_dir_name(file_name: &str) -> String { let lower = file_name.to_ascii_lowercase(); for suffix in [ + ".gnutar.gz", + ".gtar.gz", ".tar.gz", ".tar.zst", ".tar.zstd", @@ -156,6 +168,8 @@ fn expanded_dir_name(file_name: &str) -> String { ".tbz2", ".txz", ".tardist", + ".gnutar", + ".gtar", ".tar", ".sit.hqx", ".sea.hqx", @@ -404,7 +418,12 @@ fn import_dir_inner( .then(|| e.comps.last().and_then(|n| gunzip_target(&e.host, n))) .flatten() { - let len = gunzipped_len(&e.host)?; + let (len, header_mtime) = gunzip_scan(&e.host)?; + // The gzip header records the original file's date; fall back to the .gz's own. + let mut overrides = overrides; + if let Some(t) = header_mtime { + overrides.unix_times = Some(crate::fs::times::UnixTimes::mtime_only(t)); + } let f = File::open(&e.host).with_context(|| format!("opening {display}"))?; let mut dec = flate2::read::MultiGzDecoder::new(std::io::BufReader::new(f)); sink.push( @@ -533,10 +552,11 @@ fn expand_archive( comps[last] = expanded_dir_name(&file_name); } - let dir = match sink.ensure_dir_at(efs, &comps)? { - Some(d) => d, - None => return Ok(false), - }; + let dir = + match sink.ensure_dir_at(efs, &comps, crate::fs::tar_import::archive_times(&e.host))? { + Some(d) => d, + None => return Ok(false), + }; // The nested import's tally is reported against the parent's totals, so // the caller's progress callback keeps counting up rather than restarting. let base = sink.stats.clone(); @@ -946,6 +966,127 @@ mod tests { .collect() } + /// Archive dates for the test fixtures: 1994-01-04 and 1998-07-14. + const ARCHIVE_DATE: u64 = 757_641_600; + const MEMBER_DATE: u64 = 900_374_400; + + fn blank_next_ufs() -> Box { + let img = + crate::fs::ufs_format::create_blank_ufs1(&crate::fs::ufs_format::Ufs1FormatParams { + size_bytes: 8 * 1024 * 1024, + cg_layout: crate::fs::ufs::CgLayout::Bsd43, + endian: crate::fs::ufs::UfsEndian::Big, + ..Default::default() + }) + .unwrap(); + crate::fs::open_editable_filesystem(std::io::Cursor::new(img), 0, 0, None).unwrap() + } + + fn entry_at(efs: &mut dyn EditableFilesystem, path: &[&str]) -> FileEntry { + let mut e = efs.root().unwrap(); + for name in path { + e = efs + .list_directory(&e) + .unwrap() + .into_iter() + .find(|c| c.name == *name) + .unwrap_or_else(|| panic!("{name} missing")); + } + e + } + + fn set_mtime(path: &Path, secs: u64) { + let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs)) + .unwrap(); + } + + /// Directories an archive never dates take the archive's own date; its files keep theirs. + #[test] + fn expanded_archive_directories_take_the_archive_date() { + let tmp = tempfile::tempdir().unwrap(); + let tgz = tmp.path().join("pkg.tar.gz"); + { + let f = std::fs::File::create(&tgz).unwrap(); + let enc = flate2::write::GzEncoder::new(f, flate2::Compression::default()); + let mut b = tar::Builder::new(enc); + let mut h = tar::Header::new_gnu(); + h.set_size(3); + h.set_mode(0o644); + h.set_mtime(MEMBER_DATE); + h.set_cksum(); + // No entries for `deep/` or `deep/er/`: both are implicit parents. + b.append_data(&mut h, "deep/er/one.txt", &b"one"[..]) + .unwrap(); + let mut u = tar::Header::new_gnu(); + u.set_size(3); + u.set_mode(0o644); + u.set_mtime(0); + u.set_cksum(); + b.append_data(&mut u, "deep/undated.txt", &b"two"[..]) + .unwrap(); + b.into_inner().unwrap().finish().unwrap(); + } + set_mtime(&tgz, ARCHIVE_DATE); + + let mut efs = blank_next_ufs(); + let root = efs.root().unwrap(); + let opts = DirImportOptions { + expand_archives: true, + ..Default::default() + }; + import_dir(&mut *efs, &root, tmp.path(), &opts, &|_| {}).unwrap(); + for dir in [&["pkg"][..], &["pkg", "deep"], &["pkg", "deep", "er"]] { + assert_eq!( + entry_at(&mut *efs, dir).modified_unix, + Some(ARCHIVE_DATE), + "{dir:?}" + ); + } + let file = entry_at(&mut *efs, &["pkg", "deep", "er", "one.txt"]); + assert_eq!(file.modified_unix, Some(MEMBER_DATE)); + let undated = entry_at(&mut *efs, &["pkg", "deep", "undated.txt"]); + assert_eq!( + undated.modified_unix, + Some(ARCHIVE_DATE), + "mtime 0 takes the archive date" + ); + } + + /// --expand-gunzip dates the output from the gzip header, else from the .gz file. + #[test] + fn gunzipped_files_keep_the_original_date() { + use std::io::Write as _; + let tmp = tempfile::tempdir().unwrap(); + let stamped = tmp.path().join("notes.txt.gz"); + let f = std::fs::File::create(&stamped).unwrap(); + let mut enc = flate2::GzBuilder::new() + .mtime(MEMBER_DATE as u32) + .write(f, flate2::Compression::default()); + enc.write_all(b"dated").unwrap(); + enc.finish().unwrap(); + set_mtime(&stamped, ARCHIVE_DATE); + let plain = tmp.path().join("plain.bin.gz"); + write_gz(&plain, b"undated"); + set_mtime(&plain, ARCHIVE_DATE); + + let mut efs = blank_next_ufs(); + let root = efs.root().unwrap(); + let opts = DirImportOptions { + expand_gunzip: true, + ..Default::default() + }; + import_dir(&mut *efs, &root, tmp.path(), &opts, &|_| {}).unwrap(); + assert_eq!( + entry_at(&mut *efs, &["notes.txt"]).modified_unix, + Some(MEMBER_DATE) + ); + assert_eq!( + entry_at(&mut *efs, &["plain.bin"]).modified_unix, + Some(ARCHIVE_DATE) + ); + } + #[test] fn gunzipped_names_drop_only_the_gzip_suffix() { assert_eq!(gunzipped_name("pkg.tar.gz").as_deref(), Some("pkg.tar")); @@ -1171,6 +1312,13 @@ mod tests { assert_eq!(expanded_dir_name("bar.tardist"), "bar"); assert_eq!(expanded_dir_name("baz.tgz"), "baz"); assert_eq!(expanded_dir_name("qux.TAR.GZ"), "qux"); + // GNU tar's own names, common on NeXT archives. + assert_eq!( + expanded_dir_name("MiscKit.2.0.5.s.gnutar.gz"), + "MiscKit.2.0.5.s" + ); + assert_eq!(expanded_dir_name("fly.NIHS.bs.gnutar"), "fly.NIHS.bs"); + assert_eq!(expanded_dir_name("HKGnats-1.0.gtar.gz"), "HKGnats-1.0"); // Not an archive suffix -> untouched. assert_eq!(expanded_dir_name("plain.txt"), "plain.txt"); // Stripping to nothing keeps the original. diff --git a/src/fs/import_sink.rs b/src/fs/import_sink.rs index 1bdc5a84..d3dd2947 100644 --- a/src/fs/import_sink.rs +++ b/src/fs/import_sink.rs @@ -229,6 +229,9 @@ pub struct Importer { /// directory. dir_children: HashMap>, pub stats: ImportStats, + /// Date for directories the source never dated (implicit parents): the archive's own, so + /// an expanded archive does not carry the day it was imported. + pub implicit_dir_times: Option, } impl Importer { @@ -239,6 +242,7 @@ impl Importer { dir_cache, dir_children: HashMap::new(), stats: ImportStats::default(), + implicit_dir_times: None, } } @@ -286,7 +290,7 @@ impl Importer { if applied { self.stats.perms_applied += 1; } - } else if !overrides.is_empty() { + } else if overrides.has_permissions() { self.stats.perms_applied += 1; } return Ok(()); @@ -424,7 +428,7 @@ impl Importer { .insert(name.clone()); self.stats.files += 1; self.stats.total_bytes += size; - if !overrides.is_empty() { + if overrides.has_permissions() { self.stats.perms_applied += 1; } } @@ -460,12 +464,17 @@ impl Importer { &mut self, efs: &mut dyn EditableFilesystem, comps: &[String], + times: Option, ) -> Result> { if comps.iter().any(|c| efs.validate_name(c).is_err()) { self.stats.invalid_names_skipped += 1; return Ok(None); } - let dir = self.ensure_dir(efs, comps, &AttrOverrides::default())?; + let leaf = AttrOverrides { + unix_times: times, + ..Default::default() + }; + let dir = self.ensure_dir(efs, comps, &leaf)?; Ok(Some(dir)) } @@ -500,11 +509,14 @@ impl Importer { Some(e) if e.is_directory() => e, Some(_) => bail!("path component {comp:?} exists but is not a directory"), None => { - let overrides = if i == last { + let mut overrides = if i == last { *leaf_overrides } else { AttrOverrides::default() }; + if overrides.unix_times.is_none() { + overrides.unix_times = self.implicit_dir_times; + } let attrs = crate::fs::attrs::resolve_dir_attrs(&overrides, None, Some(&parent)); let dir_opts = crate::fs::filesystem::CreateDirectoryOptions { diff --git a/src/fs/mac_archive_import.rs b/src/fs/mac_archive_import.rs index d6367655..7b3e9615 100644 --- a/src/fs/mac_archive_import.rs +++ b/src/fs/mac_archive_import.rs @@ -123,6 +123,8 @@ pub fn import_mac_archive_from_path_into( // A flat destination (MFS, CP/M, …) can still receive an archive's // files; collapsing beats erroring out mid-import. flatten: true, + // An expanded archive keeps its members' own dates, not the day it was imported. + preserve_times: true, ..CopyOptions::default() }; diff --git a/src/fs/tar_import.rs b/src/fs/tar_import.rs index b01cdbbe..faf7fad6 100644 --- a/src/fs/tar_import.rs +++ b/src/fs/tar_import.rs @@ -271,15 +271,32 @@ pub fn import_tar_from_path( let n = file.read(&mut magic).unwrap_or(0); file.seek(SeekFrom::Start(0)).context("rewind archive")?; - if n >= 2 && magic[0] == 0x1f && magic[1] == 0x8b { + let times = archive_times(path); + efs.begin_bulk(); + let result = if n >= 2 && magic[0] == 0x1f && magic[1] == 0x8b { let dec = flate2::read::GzDecoder::new(file); - import_tar(efs, dest, dec, opts, progress) + import_tar_inner(efs, dest, dec, opts, progress, times) } else if n >= 4 && magic == [0x28, 0xb5, 0x2f, 0xfd] { - let dec = crate::rbformats::zstd_compat::decoder(file).context("init zstd decoder")?; - import_tar(efs, dest, dec, opts, progress) + match crate::rbformats::zstd_compat::decoder(file).context("init zstd decoder") { + Ok(dec) => import_tar_inner(efs, dest, dec, opts, progress, times), + Err(e) => Err(e), + } } else { - import_tar(efs, dest, file, opts, progress) - } + import_tar_inner(efs, dest, file, opts, progress, times) + }; + efs.end_bulk(); + result +} + +/// The archive file's own modification time: the date its undated directories inherit. +pub(crate) fn archive_times(path: &Path) -> Option { + let secs = std::fs::metadata(path) + .and_then(|m| m.modified()) + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + Some(crate::fs::times::UnixTimes::mtime_only(secs)) } /// Import an (already-decompressed, or plain) tar stream into `dest`. @@ -298,7 +315,7 @@ pub fn import_tar( progress: &dyn Fn(&TarImportStats), ) -> Result { efs.begin_bulk(); - let result = import_tar_inner(efs, dest, archive, opts, progress); + let result = import_tar_inner(efs, dest, archive, opts, progress, None); efs.end_bulk(); result } @@ -316,7 +333,7 @@ pub fn import_tar_into( opts: &TarImportOptions, progress: &dyn Fn(&TarImportStats), ) -> Result { - import_tar_inner(efs, dest, archive, opts, progress) + import_tar_inner(efs, dest, archive, opts, progress, None) } /// [`import_tar_from_path`] without the bulk-mode bracketing. See @@ -334,19 +351,15 @@ pub fn import_tar_from_path_into( let n = file.read(&mut magic).unwrap_or(0); file.seek(SeekFrom::Start(0)).context("rewind archive")?; + let times = archive_times(path); if n >= 2 && magic[0] == 0x1f && magic[1] == 0x8b { - import_tar_into( - efs, - dest, - flate2::read::GzDecoder::new(file), - opts, - progress, - ) + let dec = flate2::read::GzDecoder::new(file); + import_tar_inner(efs, dest, dec, opts, progress, times) } else if n >= 4 && magic == [0x28, 0xb5, 0x2f, 0xfd] { let dec = crate::rbformats::zstd_compat::decoder(file).context("init zstd decoder")?; - import_tar_into(efs, dest, dec, opts, progress) + import_tar_inner(efs, dest, dec, opts, progress, times) } else { - import_tar_into(efs, dest, file, opts, progress) + import_tar_inner(efs, dest, file, opts, progress, times) } } @@ -392,8 +405,10 @@ fn import_tar_inner( archive: R, opts: &TarImportOptions, progress: &dyn Fn(&TarImportStats), + dir_times: Option, ) -> Result { let mut sink = Importer::new(dest); + sink.implicit_dir_times = dir_times; // macOS tar writes `._name` right before `name`; the parsed sidecar waits // here so the primary file lands with its resource fork and type codes. let mut pending_forks: std::collections::HashMap< @@ -428,7 +443,11 @@ fn import_tar_inner( // Empty when `apply_permissions` is off, in which case the shared // resolver falls back to the replaced entry / parent directory — // the same precedence `rb-cli put` uses. - let overrides = archived_overrides(entry.header(), opts.apply_permissions); + let mut overrides = archived_overrides(entry.header(), opts.apply_permissions); + // A member the archive never dated (mtime 0) takes the archive's date, not the import day. + if overrides.unix_times.is_none() { + overrides.unix_times = dir_times; + } if entry.header().entry_type().as_byte() == b'N' { let mut list = Vec::new(); diff --git a/tests/cp_engine.rs b/tests/cp_engine.rs index 88056cff..e313fd91 100644 --- a/tests/cp_engine.rs +++ b/tests/cp_engine.rs @@ -60,6 +60,7 @@ fn opts(recursive: bool) -> CopyOptions { names: NamePolicy::Truncate, attrs: AttrPolicy::Preserve, flatten: false, + preserve_times: false, } }