Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/verbs/cp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/fs/attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 27 additions & 2 deletions src/fs/copy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -104,6 +107,7 @@ impl Default for CopyOptions {
names: NamePolicy::Truncate,
attrs: AttrPolicy::Preserve,
flatten: false,
preserve_times: false,
}
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<crate::fs::times::UnixTimes> {
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(
Expand Down Expand Up @@ -620,14 +641,18 @@ fn ensure_directory(
dst: &mut dyn EditableFilesystem,
parent: &FileEntry,
name: &str,
times: Option<crate::fs::times::UnixTimes>,
) -> Result<FileEntry> {
if let Some(existing) = find_child(dst, parent, name)? {
if existing.is_directory() {
return Ok(existing);
}
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;
Expand Down
162 changes: 155 additions & 7 deletions src/fs/dir_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,20 @@ fn gunzip_target(host: &Path, file_name: &str) -> Option<String> {
/// 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<u64> {
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<u64>)> {
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`.
Expand Down Expand Up @@ -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",
Expand All @@ -156,6 +168,8 @@ fn expanded_dir_name(file_name: &str) -> String {
".tbz2",
".txz",
".tardist",
".gnutar",
".gtar",
".tar",
".sit.hqx",
".sea.hqx",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<dyn EditableFilesystem> {
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"));
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 16 additions & 4 deletions src/fs/import_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ pub struct Importer {
/// directory.
dir_children: HashMap<String, HashSet<String>>,
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<crate::fs::times::UnixTimes>,
}

impl Importer {
Expand All @@ -239,6 +242,7 @@ impl Importer {
dir_cache,
dir_children: HashMap::new(),
stats: ImportStats::default(),
implicit_dir_times: None,
}
}

Expand Down Expand Up @@ -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(());
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -460,12 +464,17 @@ impl Importer {
&mut self,
efs: &mut dyn EditableFilesystem,
comps: &[String],
times: Option<crate::fs::times::UnixTimes>,
) -> Result<Option<FileEntry>> {
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))
}

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/fs/mac_archive_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};

Expand Down
Loading
Loading