From a76ae290d34fffe486896d8300b671312f23d2aa Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 25 Sep 2026 12:04:39 -0400 Subject: [PATCH 1/2] fix(import): expanded archives keep their own dates, not the import day Indexing the NeXTSTEP discs showed files with their 1990s dates but most directories dated the day the discs were built. Anything an archive does not date itself fell back to "now": - the folder an archive unpacks into (`pkg.tar.gz` -> `pkg/`), - implicit parents a tar lists only through `a/b/file`, - every member of a classic-Mac archive: `copy_into` never carried dates. Now, for every archive expansion and every target filesystem: - Undated directories take the archive file's own modification date (`Importer::implicit_dir_times`, `ensure_dir_at` takes a date). - Mac archive members keep their Mac dates: `CopyOptions::preserve_times`, on for archive expansion only, so `cp` / Commander still stamp the copy time. - `--expand-gunzip` output takes the original date recorded in the gzip header, else the .gz file's date. Directories and files that carry a date in the archive were already kept, and still are. Co-Authored-By: Claude Opus 5.5 --- src/cli/verbs/cp.rs | 1 + src/fs/copy.rs | 29 +++++++- src/fs/dir_import.rs | 138 +++++++++++++++++++++++++++++++++-- src/fs/import_sink.rs | 16 +++- src/fs/mac_archive_import.rs | 2 + src/fs/tar_import.rs | 49 ++++++++----- tests/cp_engine.rs | 1 + 7 files changed, 208 insertions(+), 28 deletions(-) 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/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..aa698708 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`. @@ -404,7 +414,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 +548,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 +962,114 @@ 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(); + 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)); + } + + /// --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")); diff --git a/src/fs/import_sink.rs b/src/fs/import_sink.rs index 1bdc5a84..b7898d13 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, } } @@ -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..bc6046f9 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< 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, } } From 5e85a80352db23355e11fd153c4711cbfedff45e Mon Sep 17 00:00:00 2001 From: Dani Sarfati Date: Fri, 25 Sep 2026 13:14:29 -0400 Subject: [PATCH 2/2] fix(import): date undated tar members from the archive; name .gnutar unpack folders Two leftovers from rebuilding the NeXTSTEP discs with archive dates: - Tar members recorded with mtime 0 (four .nib files in MiscKit.2.0.5.s.gnutar.gz) still took the import day. They now take the archive file's date, like the archive's undated directories. - `.gnutar.gz`, `.gtar.gz`, `.gnutar` and `.gtar` were expanded (tar is detected by content) but not recognised as archive suffixes, so the unpack folder kept the whole name: `MiscKit.2.0.5.s.gnutar.gz/`. They now strip like `.tar.gz` (`MiscKit.2.0.5.s/`). `perms_applied` counted any non-empty override, so a member that carried only a date was reported as a permission applied. It now counts mode and owner only (`AttrOverrides::has_permissions`). Co-Authored-By: Claude Opus 5.5 --- src/fs/attrs.rs | 5 +++++ src/fs/dir_import.rs | 24 ++++++++++++++++++++++++ src/fs/import_sink.rs | 4 ++-- src/fs/tar_import.rs | 6 +++++- 4 files changed, 36 insertions(+), 3 deletions(-) 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/dir_import.rs b/src/fs/dir_import.rs index aa698708..6332b0d0 100644 --- a/src/fs/dir_import.rs +++ b/src/fs/dir_import.rs @@ -156,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", @@ -166,6 +168,8 @@ fn expanded_dir_name(file_name: &str) -> String { ".tbz2", ".txz", ".tardist", + ".gnutar", + ".gtar", ".tar", ".sit.hqx", ".sea.hqx", @@ -1014,6 +1018,13 @@ mod tests { // 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); @@ -1034,6 +1045,12 @@ mod tests { } 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. @@ -1295,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 b7898d13..d3dd2947 100644 --- a/src/fs/import_sink.rs +++ b/src/fs/import_sink.rs @@ -290,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(()); @@ -428,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; } } diff --git a/src/fs/tar_import.rs b/src/fs/tar_import.rs index bc6046f9..faf7fad6 100644 --- a/src/fs/tar_import.rs +++ b/src/fs/tar_import.rs @@ -443,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();