diff --git a/.gitignore b/.gitignore index 78a9197..3e06bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /target /.idea *.log + +# Private release guard unit-test bytecode +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index cac3485..bc7e1a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.2.0 — 2026-10-21 + +- Resume staging remains owner-private (0600) until the rename publishes it; final permissions are set on the published inode to keep interruptions resumable. +- Hardened the resume-move path: sync destination parent before deleting a source, recheck original file identity, and refuse newly created target parents for moves. +- Resume staging-state errors now name both relevant hidden file paths; added identity and missing-parent regression tests. +- Opt-in Unix-only `--resume` mode for one regular file; persistent private staging, source-state manifest, advisory lock, verified prefix and full-file hashing, source retention on errors. Tested SIGKILL/restart on 256 MiB in Termux and 320 MiB on Fedora vm01; not a transactional backup. +- Hardened `--move` source-directory identity checks: a replaced empty source directory is not deleted even when no files were planned. Windows adds a creation-time comparison. +- Clarified OS page-cache hash limitations, filesystem-dependent move durability, and metadata limitations on partial destination directories. + +- Audit hardening: move no longer recursively deletes source directory trees; unexpected newly created entries or changed file identities abort source deletion. +- Move path now syncs staged file data and, on Unix, affected destination directories before source deletion. Windows directory durability remains a limitation. +- Staging files are created with owner-only permissions on Unix; accidental `-v` with file arguments fails rather than silently doing nothing. +- Added independent fail-closed public-release surface check (with explicit reviewed Unix resume opt-in), guard regression tests, and restricted CI token permissions. + +- Snapshot all source timestamps before any copying to preserve shared hardlink atime consistently. +- Added opt-in `--preserve-times` for file/dir access and modification times (Unix symlinks supported); defaults stay unchanged. +- Added five cross-platform/Unix metadata regression tests covering existing target directories, parallel copies, move/overwrite, dry run and symlink targets. + +- Do not claim "COPY VERIFIED" in progress output when `-N` disables integrity verification. +- Keep the platform-optimized fast copy path when progress is disabled; preserve performance and basic permissions. +- First milestone: live byte-counted transfer progress (`--progress auto|always|never`). +- Line-based plain-text status with throughput, copy ETA, files finished and copied bytes; designed for Termux, logs and screen readers. +- Explicit `--progress always` also reports to redirected stderr; `--quiet` and `--dry-run` suppress it. +- Added regression tests, cross-platform CI and data-loss hardening. + ## 0.1.0 — 2026-10-20 - Turned the old --count placeholder into bounded parallel file-worker processing. diff --git a/Cargo.lock b/Cargo.lock index dbb7727..d43199e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -321,13 +331,15 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "mch" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "blake2", "blake3", "clap", + "filetime", "hex", + "libc", "rayon", "sha2", "sha3", diff --git a/Cargo.toml b/Cargo.toml index 2b2e557..a6a0c68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,16 @@ [package] name = "mch" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "AGPL-3.0-or-later" description = "Mighty Copy with Hash (mch) - copy/move with post-copy verification (default: BLAKE3)." repository = "https://github.com/Olize/mch" readme = "README.md" +[package.metadata.mch-release] +# Explicit review decision; automatic version bump alone must not publish resume. +approved_unix_resume = "v1-single-regular-file" + [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } @@ -19,6 +23,9 @@ xxhash-rust = { version = "0.8", features = ["xxh3"] } hex = "0.4" rayon = "1" +filetime = "0.2.29" +[target.'cfg(unix)'.dependencies] +libc = "0.2" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index 5066da7..ec810e8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **MCH** is a Rust command-line tool for copying or moving local files and directory trees with optional post-copy integrity verification. Its default hash is **BLAKE3**. -Current version: **0.1.0**. Licensed under **GNU AGPL-3.0-or-later**. +Current version: **0.2.0**. Licensed under **GNU AGPL-3.0-or-later**. ## Features @@ -15,7 +15,7 @@ Current version: **0.1.0**. Licensed under **GNU AGPL-3.0-or-later**. - Preview all changes with `--dry-run`. - Refuse to overwrite existing files by default; use `--overwrite` explicitly. - Stage regular files in the destination directory and rename them into place only after successful verification. -- With `--move`, remove originals **only after all planned copies have completed successfully**. +- With `--move`, remove only **planned source files and subsequently empty planned directories** after successful copy/verification and destination synchronization. A new file in the source tree causes a refusal rather than recursive deletion. - Reject copying a directory into itself, duplicate destination names, destination parent symlinks, and special files (e.g. FIFOs). ## Install @@ -73,6 +73,9 @@ mch --help | `-N, --no-hash` | Disable content verification | | `--hash ALGORITHM` | Select hash algorithm; default BLAKE3 | | `--count N` | Number of parallel file workers; default 1, max 256 | +| `--progress auto|always|never` | Screenreader-friendly transfer status; default auto (only when stderr is a terminal) | +| `--preserve-times` | Preserve original access and modification times on files and newly created directories; also symlinks on Unix | +| `--resume` | Opt-in, verified restart of a single regular file on Unix; rejects `-N`, directories, multiple files and Windows | | `-O, --only-src-content` | Copy only the contents of one directory | | `--overwrite` | Explicitly replace existing files or symlinks | | `--dry-run` | Check and show plan without modifying files | @@ -83,19 +86,38 @@ mch --help When copying a directory **without** `-O`, the source directory name is always appended to the destination. When copying one regular file, an existing destination directory (or a path ending in the platform's separator) receives the source file under its original name. Without those conditions, the destination is treated as the file path. +With `-N`, the final progress status explicitly says **COPY COMPLETE (NO HASH VERIFICATION)**; MCH must never report an unverified copy as verified. + +Progress messages appear on stderr as separate, readable lines (at most once per second plus a final copy summary). They report bytes transferred, copied-file count, approximate throughput and **copy ETA**, not a guaranteed end-to-end ETA; hash verification and source deletion take additional time. `--progress always` works with redirected logs. `--quiet` and `--dry-run` suppress progress. No animated progress bars or ANSI control sequences are used in progress output. + +### Verified resumable copies (Unix single-file mode) + +```bash +mch --resume --progress always large.iso /backup/large.iso +# If interrupted, repeat exactly the same command and paths. +``` + +`--resume` supports one regular file per invocation on Unix (tested under Linux, macOS and Android/Termux). It writes a hidden owner-private `.mch-resume-v1-*.part` file alongside the destination; `.meta` binds that staging data to the initial source inode, size and modification time. A persistent `.lock` file is intentional so simultaneous resumes cannot bypass the inode lock. All previously written bytes are checked byte-for-byte against the current source on each restart; final source and staged-file hashes must match before publishing. Changing the source, altering the stage or having a conflicting destination causes a refusal, not an automatic repair. For `--resume --move`, the destination parent must already exist and support directory synchronization before the source may be deleted. The user may need to remove abandoned staging/manifest files **manually** after reviewing them. Concurrent hostile filesystem modifications, power loss and incomplete metadata/directory durability are outside the guarantees of this release. Do **not** treat this as a transactional backup, a crash-proof data store, or a safe workflow on untested network/FUSE filesystems. + ## Data safety and limitations **Important: MCH is not a transactional backup system.** +- Verification normally reads the operating-system **page cache** after writing; equal hashes do not prove that media/controller data has been durably written or will survive a power loss. +- During `--move` MCH synchronizes staged regular files before publishing and, **on Unix**, synchronizes affected destination directory entries before removing planned source files. Filesystems, controllers and power failures may still behave differently, and MCH is not a transactional backup. On Windows, destination directory persistence is not guaranteed. - MCH checks the planned paths before copying and performs verification before committing each file. It does not take a filesystem snapshot. If sources change during copying, verification may fail; sources must be kept stable for reliable results. -- An error partway through an operation may leave some **completed** destination files or newly created directories behind. Sources are retained if copying or verification fails. It is safe to inspect the destination and retry, using `--overwrite` if appropriate. +- An error partway through an operation may leave some **completed** destination files or newly created directories behind. New directories can temporarily retain default permissions/timestamps until finalization succeeds. Sources are retained if copying or verification fails. It is safe to inspect the destination and retry, using `--overwrite` if appropriate. - For a successful `--move`, original sources are deleted after verification. Deleting originals can itself fail, in which case both copies may remain. Do not assume an all-or-nothing transaction across sources. - `--overwrite` is atomic per regular file on Unix when source and destination are on the same mounted destination filesystem: the temporary file is created alongside its destination and renamed into place. On Windows, replacement may temporarily rename the old destination to a backup first; it is **not** an atomic replacement guarantee. - When `--overwrite` is not specified, existing regular files and links are never replaced. Existing destination directories may be merged; conflicting files are still rejected. - Symbolic links are preserved on Unix, but their *textual targets* are checked rather than hashing the objects they refer to. Windows symbolic-link copying is rejected. -- Directory ownership, hard-link relationships, extended attributes, ACLs, creation times, and modification times are **not** preserved. Do not use MCH as a metadata-perfect backup. +- `--preserve-times` optionally restores file access/modification times, newly created directory times, and Unix symlink times. Existing destination directories are never assigned source timestamps. Without this option, timestamps are not preserved intentionally. Timestamp operations can fail on unsupported filesystems and are not guarantees against external changes. +- Directory ownership, hard-link relationships, extended attributes, ACLs, birth/creation times and sparse extents are **not** preserved. Do not use MCH as a metadata-perfect backup. - Special files (devices, sockets, FIFOs) are not copied. - Destination paths whose existing parent components are symbolic links are rejected deliberately to avoid writing through an unexpected alias. On macOS, the standard system-managed `/var`, `/tmp`, and `/etc` aliases into `/private` are normalized after verifying their expected targets; other symlink parents remain forbidden. +- MCH reads both source and staged target for a hash comparison, usually **from the operating system's page cache**. This detects mismatches visible through filesystem reads but **does not establish physical-media durability or prove that the controller stored the data correctly**. +- For `--move` on Unix, MCH syncs staged data and relevant destination directory entries before removing planned sources. Actual power-loss durability still depends on the operating system and filesystem; source-directory deletion is not itself transactionally committed. On Windows, no equivalent directory-fsync guarantee is claimed. +- If copying fails after creating destination directories, partially created directories may retain default mode/timestamps until a later successful run. Inspect partial results before retrying, especially when copying restrictive directory permissions. - MCH does not protect against concurrent malicious filesystem mutation (TOCTOU), disk/controller faults below the filesystem, or corruption that occurs after verification. - Using `-N` disables post-copy integrity verification, including for moves. diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 91d0e9e..0d45f9e 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -1,4 +1,4 @@ -# Technical specification — MCH 0.1.0 +# Technical specification — MCH 0.2.0 Status: implementation contract, not a formal safety proof. @@ -21,28 +21,43 @@ Supports regular files, directory trees and Unix symlinks; does not copy sockets - --dry-run: preflight and print proposed operations, no changes. - -q: quiet output. - --no-color: disable ANSI colors. +- --progress auto|always|never: no-ANSI line-oriented status to stderr; auto only with a terminal, quiet and dry-run suppress progress. +- --preserve-times: opt-in snapshot and restoration of access/modified timestamps for copied files and newly created destination directories; Unix symlink timestamps are preserved on the link itself. +- --resume: opt-in Unix single-regular-file resume with prefix checking and compulsory full hash verification. Refuses no-hash (-N), --count other than 1, -O, multiple sources and non-regular inputs; not yet available on Windows. - -v: version, -h: help. ## Copy/verification process -1. Normalize source and target paths, enumerate source trees, check targets and detect collisions and self-recursive copies. -2. Create missing destination directories without traversing existing symlink parents. -3. Copy regular files into temporary files alongside their destinations. When enabled, independently hash the original and staged copy and compare them. Symlinks are recreated, not dereferenced, and link text is compared. -4. Publish staged files with rename. On Unix this gives per-file atomic replacement on the destination filesystem. On Windows replacement may require moving the original aside first. +1. Normalize source and target paths, enumerate source trees, check targets and detect collisions and self-recursive copies. If --preserve-times is enabled, snapshot timestamps of all source files and links during planning, before processing any data. +2. Create missing destination directories without traversing existing symlink parents. Sum planned regular-file sizes for progress; this is an estimate if source files are modified by another process. +3. Copy regular files into temporary files alongside their destinations. When progress is displayed, read and write in 1 MiB blocks and count bytes; otherwise use platform-optimized fs::copy for speed. When enabled, independently hash the original and staged copy and compare them. Symlinks are recreated, not dereferenced, and link text is compared. +4. With --preserve-times, apply original timestamps to the staged file/link only after copying and hashing succeed; restore timestamps of newly created directories after all children are written. Pre-existing target directories keep their own timestamps aside from normal filesystem updates. Publish staged files with rename. On Unix this gives per-file atomic replacement on the destination filesystem. On Windows replacement may require moving the original aside first. 5. Restore permissions on new directory objects and delete original sources only after all copy tasks complete successfully. Preflight errors must leave originals untouched. Failures in the copy phase retain originals but may leave successfully copied destination files behind. Errors during final source deletion can leave some originals intact and some removed. MCH is NOT an all-or-nothing transaction. +## Unix resume protocol v1 (opt-in, supported scope) + +- The source/destination path pair determines three separate hidden sibling files: a persistent `.part` staging file, a `.meta` record of source dev/inode/length/mtime, and a permanent `.lock` file. The lock is advisory OS-level `flock` and intentionally retained across runs to avoid lock-inode replacement races. +- Persistent resume objects are opened without following the final path component, with private owner permissions and a single hardlink. Conflicts or suspicious files are refused rather than deleted. +- A partial prefix is independently compared byte-for-byte against current source bytes. The original file must retain its identity, length and modification time. Before publishing, the entire source and completed staged copy are independently hashed and compared. Resume never disables hashing. +- The progress reporter separates already verified/reused bytes from new transfer bytes; copy ETA only estimates transfer. +- After successful independent verification and metadata application, the stage is renamed to its destination. The source is removed only after success when --move is selected. +- A kill/power interruption can leave incomplete staging and metadata. A mismatched or missing metadata pair is a deliberate hard error, not silently reset. The source/destination filesystem must provide safe regular-file and rename semantics. +- This does **not** defend against hostile races or a same-inode/same-size/same-mtime content mutation, cannot guarantee directory durability on power loss, and does not resume directory trees, multiple files or Windows copies. + ## Limitations and security boundaries - No filesystem snapshots: sources should remain unchanged while MCH runs. - Concurrency with a hostile filesystem actor creates race conditions (TOCTOU), and is not defended against. -- No durability guarantees on power loss; no explicit fsync. -- No preservation guarantee for owner, ACL, xattrs, timestamps, sparse extents or hard-link relationships. Basic file permissions and newly created directory permissions are handled. +- No absolute durability guarantees after power loss. Unix moves sync staged regular files and affected destination directories before deleting source files. Unix resume synchronizes the stage and, for resume moves, the final destination directory before source deletion; noncooperating filesystems/controllers remain outside the guarantee. +- No preservation guarantee for owner, ACL, xattrs, creation times, sparse extents or hard-link relationships. Basic file permissions and newly created directory permissions are handled. Access and modification timestamps are an explicit opt-in; filesystem precision and time metadata support vary. - Unix symlinks are preserved as links; Windows symlink copying is unsupported. - Destination paths with preexisting symlink ancestors are refused intentionally, except verified macOS system roots `/var`, `/tmp` and `/etc`, which are normalized to `/private` before checking. - Non-local/cloud file systems can have unusual rename behavior. - With -N, the integrity checks are explicitly disabled. +- Progress estimates only the data-transfer phase; verification, metadata application and deleting sources are not included in the copy ETA. Progress "COPY VERIFIED" refers to hashed/copied files, not the entire move operation. With -N, the final progress state is explicitly "COPY COMPLETE (NO HASH VERIFICATION)". +- Line-based output is intended for screen readers; automatic progress is enabled only if stderr is connected to a terminal. - Temporary files may remain if the process is killed or power fails. ## Release gates diff --git a/src/engine.rs b/src/engine.rs index e75571c..c2f8db0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,12 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::{Cli, hashing::hash_file}; +use crate::{Cli, hashing::hash_file, progress::Progress}; use anyhow::{Context, Result, anyhow, bail}; +use filetime::FileTime; use rayon::prelude::*; use std::collections::HashSet; use std::ffi::OsString; use std::fs; -use std::io::ErrorKind; +use std::io::{ErrorKind, Read, Write}; use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -23,18 +24,24 @@ struct FileTask { source: PathBuf, destination: PathBuf, kind: Kind, + source_times: Option<(FileTime, FileTime)>, + move_identity: Option, } struct DirTask { source: PathBuf, destination: PathBuf, preexisting: bool, + source_atime: FileTime, + source_mtime: FileTime, + move_identity: Option, } pub(crate) struct Plan { files: Vec, directories: Vec, sources: Vec, + total_bytes: u64, } fn absolute(path: &Path) -> Result { @@ -128,7 +135,7 @@ fn no_symlink_parents(path: &Path) -> Result<()> { Ok(()) } -fn ensure_directory(path: &Path) -> Result<()> { +pub(crate) fn ensure_directory(path: &Path) -> Result<()> { // Do not follow destination symlinks, including those inserted after preflight. let mut missing = Vec::new(); let mut cursor = path; @@ -166,7 +173,11 @@ fn ensure_directory(path: &Path) -> Result<()> { Ok(()) } -fn check_destination_file(source: &Path, destination: &Path, overwrite: bool) -> Result<()> { +pub(crate) fn check_destination_file( + source: &Path, + destination: &Path, + overwrite: bool, +) -> Result<()> { no_symlink_parents(destination)?; match existing_metadata(destination)? { Some(meta) if meta.is_dir() => { @@ -210,6 +221,8 @@ fn register_file(plan: &mut Plan, source: &Path, target: &Path, kind: Kind) { source: source.to_path_buf(), destination: target.to_path_buf(), kind, + source_times: None, + move_identity: None, }); } @@ -220,10 +233,16 @@ fn collect_directory(plan: &mut Plan, source: &Path, destination: &Path) -> Resu Some(_) => bail!("destination is not a directory: {}", destination.display()), None => false, }; + // Snapshot directory times BEFORE read_dir mutates its access time. + let source_meta = fs::metadata(source) + .with_context(|| format!("stat source directory {}", source.display()))?; plan.directories.push(DirTask { source: source.to_path_buf(), destination: destination.to_path_buf(), preexisting, + source_atime: FileTime::from_last_access_time(&source_meta), + source_mtime: FileTime::from_last_modification_time(&source_meta), + move_identity: None, }); let mut entries = fs::read_dir(source) @@ -294,6 +313,7 @@ pub(crate) fn build_plan(cli: &Cli, destination: &Path) -> Result { files: Vec::new(), directories: Vec::new(), sources, + total_bytes: 0, }; for source in plan.sources.clone() { let kind = fs::symlink_metadata(&source) @@ -374,6 +394,15 @@ pub(crate) fn build_plan(cli: &Cli, destination: &Path) -> Result { ); } check_destination_file(&task.source, &task.destination, cli.overwrite)?; + if matches!(task.kind, Kind::Regular) { + let size = fs::metadata(&task.source) + .with_context(|| format!("read file size: {}", task.source.display()))? + .len(); + plan.total_bytes = plan + .total_bytes + .checked_add(size) + .context("total source size exceeds u64 range")?; + } } // Detect file paths that would become parents of any planned target. for dir in &plan.directories { @@ -381,6 +410,35 @@ pub(crate) fn build_plan(cli: &Cli, destination: &Path) -> Result { bail!("directory/file conflict: {}", dir.destination.display()); } } + if cli.move_mode { + // Save source identities before any copying. Files are never deleted + // merely because the same name happens to exist later. + for task in &mut plan.files { + task.move_identity = Some( + fs::symlink_metadata(&task.source) + .with_context(|| format!("snapshot move source {}", task.source.display()))?, + ); + } + for directory in &mut plan.directories { + directory.move_identity = + Some(fs::symlink_metadata(&directory.source).with_context(|| { + format!("snapshot move directory {}", directory.source.display()) + })?); + } + } + if cli.preserve_times { + // Snapshot EVERY source inode before the first copy/verification read. + // Hard-linked source names share an atime; a per-worker snapshot would + // give later workers a newer value after the first source was read. + for task in &mut plan.files { + let meta = fs::symlink_metadata(&task.source) + .with_context(|| format!("snapshot source times {}", task.source.display()))?; + task.source_times = Some(( + FileTime::from_last_access_time(&meta), + FileTime::from_last_modification_time(&meta), + )); + } + } Ok(plan) } @@ -391,6 +449,20 @@ impl Drop for TempPath { } } +fn reserve_private_stage(path: &Path) -> Result { + // The temporary inode starts as 0600 even when the user's umask is 022. + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .with_context(|| format!("reserve temporary file {}", path.display())) +} + fn new_temp_path(destination: &Path) -> Result { let parent = destination.parent().context("destination has no parent")?; let index = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); @@ -399,7 +471,7 @@ fn new_temp_path(destination: &Path) -> Result { )) } -fn atomic_replace(stage: &Path, destination: &Path) -> Result<()> { +pub(crate) fn atomic_replace(stage: &Path, destination: &Path) -> Result<()> { #[cfg(windows)] { if fs::symlink_metadata(destination).is_ok() { @@ -428,7 +500,7 @@ fn atomic_replace(stage: &Path, destination: &Path) -> Result<()> { .with_context(|| format!("publish {} -> {}", stage.display(), destination.display())) } -fn process_file(cli: &Cli, task: &FileTask) -> Result<()> { +fn process_file(cli: &Cli, task: &FileTask, progress: Option<&Progress>) -> Result<()> { let parent = task .destination .parent() @@ -440,19 +512,42 @@ fn process_file(cli: &Cli, task: &FileTask) -> Result<()> { match task.kind { Kind::Regular => { // Reserving exclusively makes stale temporary files non-destructive. - let reserved = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary.0) - .with_context(|| format!("reserve temporary file {}", temporary.0.display()))?; - drop(reserved); // Release the handle before copying/renaming, especially on Windows. - fs::copy(&task.source, &temporary.0).with_context(|| { - format!( - "copy {} -> {}", - task.source.display(), - temporary.0.display() - ) - })?; + let mut staged = reserve_private_stage(&temporary.0)?; + if let Some(progress) = progress { + // Count bytes only when progress is visible. Streaming is needed + // so one large file can report progress before completion. + let mut source = fs::File::open(&task.source) + .with_context(|| format!("open source {}", task.source.display()))?; + let source_permissions = source.metadata()?.permissions(); + let mut buffer = [0_u8; 1024 * 1024]; + loop { + let size = source + .read(&mut buffer) + .with_context(|| format!("read source {}", task.source.display()))?; + if size == 0 { + break; + } + staged + .write_all(&buffer[..size]) + .with_context(|| format!("write staged file {}", temporary.0.display()))?; + progress.add_bytes(size as u64); + } + drop(staged); + drop(source); + fs::set_permissions(&temporary.0, source_permissions) + .with_context(|| format!("copy file permissions {}", temporary.0.display()))?; + } else { + // Keep platform-optimized filesystem copying (e.g. copy_file_range) + // when progress is off; do not incur a streaming performance cost. + drop(staged); + fs::copy(&task.source, &temporary.0).with_context(|| { + format!( + "copy {} -> {}", + task.source.display(), + temporary.0.display() + ) + })?; + } if !cli.no_hash { let original_hash = hash_file(&task.source, cli.hash)?; @@ -482,6 +577,69 @@ fn process_file(cli: &Cli, task: &FileTask) -> Result<()> { } } + // Apply times to the temporary object before publishing it atomically. + // Symlink times must never be applied through the link to its target. + if let Some((atime, mtime)) = task.source_times { + match task.kind { + Kind::Regular => filetime::set_file_times(&temporary.0, atime, mtime) + .with_context(|| format!("preserve file times {}", temporary.0.display()))?, + Kind::Symlink => { + #[cfg(unix)] + filetime::set_symlink_file_times(&temporary.0, atime, mtime) + .with_context(|| format!("preserve symlink times {}", temporary.0.display()))?; + #[cfg(not(unix))] + bail!("symlink timestamps unsupported on this platform"); + } + } + } + + // For a move, flush the complete staged file AND its metadata before + // publishing. Hashing may have read cached data, not durable storage. + if cli.move_mode && matches!(task.kind, Kind::Regular) { + #[cfg(not(windows))] + fs::File::open(&temporary.0) + .with_context(|| format!("open staged data for sync {}", temporary.0.display()))? + .sync_all() + .with_context(|| format!("sync staged data {}", temporary.0.display()))?; + #[cfg(windows)] + { + // Windows FlushFileBuffers requires a GENERIC_WRITE file handle; + // sync_all on File::open (read-only) returns AccessDenied. + // fs::copy may also have marked the staged file read-only. + let permissions = fs::metadata(&temporary.0)?.permissions(); + if permissions.readonly() { + let mut writable = permissions.clone(); + // On Windows this toggles FILE_ATTRIBUTE_READONLY only; + // Unix executable-bit concerns from this Clippy lint do not apply. + #[allow(clippy::permissions_set_readonly_false)] + writable.set_readonly(false); + fs::set_permissions(&temporary.0, writable)?; + } + let sync_result = (|| -> Result<()> { + let staged = fs::OpenOptions::new() + .write(true) + .open(&temporary.0) + .with_context(|| { + format!( + "open writable staged data for sync {}", + temporary.0.display() + ) + })?; + if permissions.readonly() { + // Restore the original source's readonly flag before sync. + fs::set_permissions(&temporary.0, permissions.clone())?; + } + staged + .sync_all() + .with_context(|| format!("sync staged data {}", temporary.0.display())) + })(); + if sync_result.is_err() && permissions.readonly() { + let _ = fs::set_permissions(&temporary.0, permissions); + } + sync_result?; + } + } + // Recheck before committing: the target might have changed during a long copy. check_destination_file(&task.source, &task.destination, cli.overwrite)?; atomic_replace(&temporary.0, &task.destination)?; @@ -501,10 +659,187 @@ fn process_file(cli: &Cli, task: &FileTask) -> Result<()> { ), true, ); + if let Some(progress) = progress { + progress.file_finished(); + } + Ok(()) +} + +// A move is a two-phase operation: publish verified files, then remove ONLY +// the sources that were explicitly recorded in the plan. Never recursively +// delete the source tree: another application may have written new content. +fn check_move_source_snapshot(plan: &Plan) -> Result<()> { + let mut planned = HashSet::new(); + for file in &plan.files { + planned.insert(file.source.clone()); + let original = file + .move_identity + .as_ref() + .context("missing pre-copy move source identity")?; + let now = fs::symlink_metadata(&file.source) + .with_context(|| format!("move source disappeared: {}", file.source.display()))?; + if now.file_type() != original.file_type() + || now.len() != original.len() + || now.modified()? != original.modified()? + { + bail!( + "move source was changed or replaced; refusing deletion: {}", + file.source.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if now.dev() != original.dev() || now.ino() != original.ino() { + bail!( + "move source inode changed; refusing deletion: {}", + file.source.display() + ); + } + } + } + for dir in &plan.directories { + planned.insert(dir.source.clone()); + } + for dir in &plan.directories { + let original = dir + .move_identity + .as_ref() + .context("missing pre-copy move directory identity")?; + let current = fs::symlink_metadata(&dir.source)?; + if !current.is_dir() || current.modified()? != original.modified()? { + bail!("move source directory changed: {}", dir.source.display()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if current.dev() != original.dev() || current.ino() != original.ino() { + bail!( + "move source directory inode changed: {}", + dir.source.display() + ); + } + } + #[cfg(windows)] + { + // File-index / volume ID from std::MetadataExt are unstable on + // stable Rust. Creation-time + modification-time are the strongest + // portable standard-library check we can apply on Windows here. + use std::os::windows::fs::MetadataExt; + if current.creation_time() != original.creation_time() { + bail!( + "move source directory was replaced: {}", + dir.source.display() + ); + } + } + for entry in fs::read_dir(&dir.source) + .with_context(|| format!("inspect move source {}", dir.source.display()))? + { + let path = entry?.path(); + if !planned.contains(&path) { + bail!( + "move source contains a new/unplanned entry; nothing deleted: {}", + path.display() + ); + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn move_directories_to_sync(plan: &Plan) -> Result> { + use std::collections::BTreeSet; + // Snapshot which destination ancestors are MISSING *before* creating any. + // fsync only affected directories and the parent of the highest new + // directory. Unrelated ancestors (including / on Android) are not ours. + let mut affected = BTreeSet::new(); + for file in &plan.files { + let parent = file + .destination + .parent() + .context("destination without parent")?; + affected.insert(parent.to_path_buf()); + let mut current = parent; + loop { + if existing_metadata(current)?.is_some() { + break; + } + let outer = current.parent().context("no existing directory ancestor")?; + affected.insert(outer.to_path_buf()); + current = outer; + } + } + for dir in &plan.directories { + affected.insert(dir.destination.clone()); // empty or modified directory + if !dir.preexisting { + // An empty tree may contain no FileTask at all: ensure every + // newly created ancestor and its first existing parent are synced. + let mut current = dir.destination.as_path(); + loop { + if existing_metadata(current)?.is_some() { + break; + } + let parent = current.parent().context("directory without parent")?; + affected.insert(parent.to_path_buf()); + current = parent; + } + } + } + // Data and directory entries are synced from deepest to shallowest. + let mut dirs: Vec<_> = affected.into_iter().collect(); + dirs.sort_by_key(|dir| std::cmp::Reverse(dir.components().count())); + Ok(dirs) +} + +#[cfg(unix)] +fn sync_move_destinations(dirs: &[PathBuf]) -> Result<()> { + for directory in dirs { + fs::File::open(directory) + .with_context(|| { + format!( + "open destination directory for sync {}", + directory.display() + ) + })? + .sync_all() + .with_context(|| { + format!( + "sync destination directory before source removal {}", + directory.display() + ) + })?; + } Ok(()) } pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { + if cli.resume { + if plan.files.len() != 1 + || !plan.directories.is_empty() + || !matches!(plan.files[0].kind, Kind::Regular) + { + bail!("--resume only supports one regular file, not directories or symlinks"); + } + if cli.dry_run { + cli.report( + &format!( + "RESUME DRY RUN {} -> {}", + plan.files[0].source.display(), + plan.files[0].destination.display() + ), + true, + ); + return Ok(()); + } + return crate::resume::copy( + cli, + &plan.files[0].source, + &plan.files[0].destination, + plan.files[0].source_times, + ); + } if cli.dry_run { for dir in &plan.directories { cli.report(&format!("MKDIR {}", dir.destination.display()), true); @@ -534,6 +869,12 @@ pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { return Ok(()); } + #[cfg(unix)] + let move_sync_directories = if cli.move_mode { + move_directories_to_sync(plan)? + } else { + Vec::new() + }; for dir in &plan.directories { ensure_directory(&dir.destination)?; } @@ -541,10 +882,29 @@ pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { .num_threads(cli.count) .build() .context("create worker pool")?; - pool.install(|| { - plan.files - .par_iter() - .try_for_each(|task| process_file(cli, task)) + let tracker = Progress::new(plan.total_bytes, plan.files.len() as u64, !cli.no_hash); + let show_progress = cli.show_progress(); + std::thread::scope(|scope| -> Result<()> { + let (sender, receiver) = std::sync::mpsc::channel::(); + let reporter = if show_progress { + let tracker_ref = &tracker; + Some(scope.spawn(move || crate::progress::watch(tracker_ref, receiver))) + } else { + None + }; + let progress = show_progress.then_some(&tracker); + let result = pool.install(|| { + plan.files + .par_iter() + .try_for_each(|task| process_file(cli, task, progress)) + }); + if let Some(reporter) = reporter { + let _ = sender.send(result.is_ok()); + reporter + .join() + .map_err(|_| anyhow!("progress reporter thread panicked"))?; + } + result })?; // Preserve source directory permissions where we actually created a directory. @@ -561,18 +921,46 @@ pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { directory.destination.display() ) })?; + if cli.preserve_times { + // Restoring after writing descendants prevents child creation + // from invalidating the copied directory's modification time. + filetime::set_file_times( + &directory.destination, + directory.source_atime, + directory.source_mtime, + ) + .with_context(|| { + format!( + "preserve directory times {}", + directory.destination.display() + ) + })?; + } } } if cli.move_mode { - // IMPORTANT: none of the originals are removed until every planned copy has verified. + // Fail closed if the input tree gained ANY unplanned entry after + // enumeration. Another process may be actively writing to it. + check_move_source_snapshot(plan)?; + #[cfg(unix)] + sync_move_destinations(&move_sync_directories)?; + // Remove only planned files and links. Never use remove_dir_all. + for file in &plan.files { + fs::remove_file(&file.source) + .with_context(|| format!("remove planned move source {}", file.source.display()))?; + } + // Remove only empty, planned directories, deepest first. If new + // entries appear during deletion, remove_dir fails and retains them. + for directory in plan.directories.iter().rev() { + fs::remove_dir(&directory.source).with_context(|| { + format!( + "remove planned empty source directory {}", + directory.source.display() + ) + })?; + } for source in &plan.sources { - let ty = fs::symlink_metadata(source)?.file_type(); - if ty.is_dir() { - fs::remove_dir_all(source)?; - } else { - fs::remove_file(source)?; - } cli.report(&format!("MOVED {}", source.display()), true); } } @@ -590,3 +978,116 @@ pub(crate) fn execute(cli: &Cli, plan: &Plan) -> Result<()> { } Ok(()) } + +#[cfg(test)] +mod move_safety_tests { + use super::*; + use clap::Parser; + + #[cfg(unix)] + #[test] + fn staged_regular_file_is_private_from_creation() { + use std::os::unix::fs::PermissionsExt; + let t = tempfile::tempdir().unwrap(); + let stage = t.path().join(".mch-tmp-test"); + let file = reserve_private_stage(&stage).unwrap(); + assert_eq!(file.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } + + #[test] + fn move_refuses_new_entries_after_planning_without_deleting_any_sources() { + let t = tempfile::tempdir().unwrap(); + let src = t.path().join("ongoing-download"); + let nested = src.join("nested"); + let dest = t.path().join("backup"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("original.txt"), b"first copied").unwrap(); + let cli = Cli::try_parse_from([ + std::ffi::OsString::from("mch"), + std::ffi::OsString::from("--move"), + src.as_os_str().to_os_string(), + dest.as_os_str().to_os_string(), + ]) + .unwrap(); + let plan = build_plan(&cli, &dest).unwrap(); + // A separate application adds a new file after the source scan. + fs::write( + nested.join("just-created.txt"), + b"never part of the copy plan", + ) + .unwrap(); + let error = execute(&cli, &plan).unwrap_err(); + assert!( + error.to_string().contains("unplanned") || error.to_string().contains("changed"), + "incorrect failure: {error:#}" + ); + assert_eq!( + fs::read(nested.join("original.txt")).unwrap(), + b"first copied" + ); + assert_eq!( + fs::read(nested.join("just-created.txt")).unwrap(), + b"never part of the copy plan" + ); + assert_eq!( + fs::read(dest.join("ongoing-download/nested/original.txt")).unwrap(), + b"first copied" + ); + assert!( + !dest + .join("ongoing-download/nested/just-created.txt") + .exists() + ); + } + + #[test] + fn move_refuses_replaced_empty_source_directory_after_planning() { + let t = tempfile::tempdir().unwrap(); + let source = t.path().join("original-dir"); + let backup = t.path().join("saved-original"); + let destination = t.path().join("target"); + fs::create_dir(&source).unwrap(); + let cli = Cli::try_parse_from([ + std::ffi::OsString::from("mch"), + std::ffi::OsString::from("--move"), + source.as_os_str().to_os_string(), + destination.as_os_str().to_os_string(), + ]) + .unwrap(); + let plan = build_plan(&cli, &destination).unwrap(); + // Another program replaced the empty directory with a different + // empty directory of the SAME name. Never remove this new inode. + fs::rename(&source, &backup).unwrap(); + fs::create_dir(&source).unwrap(); + // Make modification timestamps identical so the regression also + // exercises inode/creation-time identity checks rather than mtime. + let original_mtime = FileTime::from_last_modification_time(&fs::metadata(&backup).unwrap()); + let replacement_atime = FileTime::from_last_access_time(&fs::metadata(&source).unwrap()); + filetime::set_file_times(&source, replacement_atime, original_mtime).unwrap(); + let result = execute(&cli, &plan); + assert!(result.is_err(), "must reject replaced source directory"); + assert!(source.is_dir(), "new source directory was destroyed"); + assert!(backup.is_dir(), "original directory was destroyed"); + } + + #[test] + fn move_refuses_replaced_source_after_planning() { + let t = tempfile::tempdir().unwrap(); + let source = t.path().join("source.bin"); + let dest = t.path().join("destination.bin"); + fs::write(&source, b"before").unwrap(); + let cli = Cli::try_parse_from([ + std::ffi::OsString::from("mch"), + std::ffi::OsString::from("--move"), + source.as_os_str().to_os_string(), + dest.as_os_str().to_os_string(), + ]) + .unwrap(); + let plan = build_plan(&cli, &dest).unwrap(); + fs::remove_file(&source).unwrap(); + fs::write(&source, b"replacement-longer").unwrap(); + let err = execute(&cli, &plan).unwrap_err(); + assert!(err.to_string().contains("changed"), "{err:#}"); + assert_eq!(fs::read(source).unwrap(), b"replacement-longer"); + } +} diff --git a/src/main.rs b/src/main.rs index 686f1c3..b1d516a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,8 @@ mod engine; mod hashing; +mod progress; +mod resume; use anyhow::Result; use clap::{Parser, ValueEnum}; @@ -35,6 +37,13 @@ impl HashAlg { } } +#[derive(Copy, Clone, Debug, ValueEnum, Eq, PartialEq)] +enum ProgressMode { + Auto, + Always, + Never, +} + #[derive(Parser, Debug)] #[command( name = "mch", @@ -84,6 +93,18 @@ struct Cli { #[arg(long = "no-color")] no_color: bool, + /// Line-based transfer progress: auto (TTY), always, or never + #[arg(long = "progress", value_enum, default_value_t = ProgressMode::Auto)] + progress: ProgressMode, + + /// Preserve access and modification times on copied items (opt-in) + #[arg(long = "preserve-times")] + preserve_times: bool, + + /// Restart a partially copied single regular file (Unix only; experimental) + #[arg(long = "resume")] + resume: bool, + /// One or more source files/directories #[arg(required = true, num_args = 1..)] sources: Vec, @@ -98,6 +119,16 @@ impl Cli { !self.no_color && std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } + fn show_progress(&self) -> bool { + !self.quiet + && !self.dry_run + && match self.progress { + ProgressMode::Auto => std::io::stderr().is_terminal(), + ProgressMode::Always => true, + ProgressMode::Never => false, + } + } + fn report(&self, line: &str, ok: bool) { if self.quiet { return; @@ -123,11 +154,7 @@ fn run() -> Result<()> { } let cli = Cli::parse(); if cli.version { - println!( - "Mighty Copy with Hash\nCopyright (C) 2026 Olize\n{}", - env!("CARGO_PKG_VERSION") - ); - return Ok(()); + anyhow::bail!("-v/--version must be used alone; no files were copied"); } if !cli.quiet { @@ -146,6 +173,18 @@ fn run() -> Result<()> { if cli.only_src_content && cli.sources.len() != 1 { anyhow::bail!("-O/--only-src-content requires exactly one source directory"); } + if cli.resume { + if cli.no_hash { + anyhow::bail!("--resume requires hash verification; remove -N/--no-hash"); + } + if cli.sources.len() != 1 || cli.only_src_content || cli.count != 1 { + anyhow::bail!( + "--resume currently supports exactly one regular source file, --count 1, without -O" + ); + } + #[cfg(not(unix))] + anyhow::bail!("--resume is not yet supported on this platform"); + } let plan = engine::build_plan(&cli, &cli.destination)?; engine::execute(&cli, &plan) } diff --git a/src/progress.rs b/src/progress.rs new file mode 100644 index 0000000..49471ad --- /dev/null +++ b/src/progress.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Screenreader-friendly, line-oriented progress. No carriage returns or ANSI. +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +pub(crate) struct Progress { + copied: AtomicU64, + finished: AtomicU64, + total_bytes: u64, + total_files: u64, + hash_verification: bool, + started: Instant, +} + +impl Progress { + pub(crate) fn new(total_bytes: u64, total_files: u64, hash_verification: bool) -> Self { + Self { + copied: AtomicU64::new(0), + finished: AtomicU64::new(0), + total_bytes, + total_files, + hash_verification, + started: Instant::now(), + } + } + + pub(crate) fn add_bytes(&self, bytes: u64) { + self.copied.fetch_add(bytes, Ordering::Relaxed); + } + + pub(crate) fn file_finished(&self) { + self.finished.fetch_add(1, Ordering::Relaxed); + } + + fn snapshot(&self, successful: Option) { + let copied = self.copied.load(Ordering::Relaxed); + let finished = self.finished.load(Ordering::Relaxed); + let elapsed = self.started.elapsed().as_secs_f64(); + let rate = if elapsed > 0.0 { + copied as f64 / elapsed + } else { + 0.0 + }; + let percent = if self.total_bytes == 0 { + if finished == self.total_files { + 100.0 + } else { + 0.0 + } + } else { + (copied as f64 * 100.0 / self.total_bytes as f64).min(100.0) + }; + let eta = if successful == Some(true) { + String::from("0s") + } else if rate > 0.0 && copied < self.total_bytes { + let secs = ((self.total_bytes - copied) as f64 / rate).ceil() as u64; + format_duration(secs) + } else { + String::from("unknown") + }; + let state = match successful { + Some(true) if self.hash_verification => "COPY VERIFIED", + Some(true) => "COPY COMPLETE (NO HASH VERIFICATION)", + Some(false) => "FAILED", + None if copied >= self.total_bytes && finished < self.total_files => { + if self.hash_verification { + "VERIFYING" + } else { + "FINALIZING" + } + } + None => "COPYING", + }; + eprintln!( + "PROGRESS {state}: {percent:.1}% | {} / {} copied | {finished}/{} files | {}/s | copy ETA {eta}", + human_bytes(copied), + human_bytes(self.total_bytes), + self.total_files, + human_bytes(rate as u64), + ); + } +} + +fn human_bytes(bytes: u64) -> String { + let mut value = bytes as f64; + let mut unit = "B"; + for next in ["KiB", "MiB", "GiB", "TiB", "PiB"] { + if value < 1024.0 { + break; + } + value /= 1024.0; + unit = next; + } + if unit == "B" { + format!("{bytes} B") + } else { + format!("{value:.1} {unit}") + } +} + +fn format_duration(seconds: u64) -> String { + if seconds < 60 { + format!("{seconds}s") + } else if seconds < 3600 { + format!("{}m {}s", seconds / 60, seconds % 60) + } else { + format!("{}h {}m", seconds / 3600, (seconds % 3600) / 60) + } +} + +// Called on a scoped thread. Stop immediately when the copy completes; +// don't wait for the reporting interval before returning to the CLI. +pub(crate) fn watch(progress: &Progress, receiver: Receiver) { + loop { + match receiver.recv_timeout(Duration::from_secs(1)) { + Ok(success) => { + progress.snapshot(Some(success)); + break; + } + Err(RecvTimeoutError::Timeout) => progress.snapshot(None), + Err(RecvTimeoutError::Disconnected) => { + progress.snapshot(Some(false)); + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn human_byte_units_are_iec() { + assert_eq!(human_bytes(0), "0 B"); + assert_eq!(human_bytes(1023), "1023 B"); + assert_eq!(human_bytes(1024), "1.0 KiB"); + assert_eq!(human_bytes(1024 * 1024), "1.0 MiB"); + } + + #[test] + fn duration_is_readable() { + assert_eq!(format_duration(14), "14s"); + assert_eq!(format_duration(65), "1m 5s"); + assert_eq!(format_duration(3660), "1h 1m"); + } +} diff --git a/src/resume.rs b/src/resume.rs new file mode 100644 index 0000000..20eaaff --- /dev/null +++ b/src/resume.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// Fail-closed, opt-in, single-file Unix resume protocol v1. +// Windows is deliberately unsupported until secure staging/locking is reviewed. +use crate::Cli; +use anyhow::{Result, bail}; +use filetime::FileTime; +use std::path::Path; + +#[cfg(not(unix))] +pub(crate) fn copy( + _cli: &Cli, + _source: &Path, + _destination: &Path, + _source_times: Option<(FileTime, FileTime)>, +) -> Result<()> { + bail!("--resume is not supported on this platform") +} + +#[cfg(unix)] +pub(crate) use unix::copy; + +#[cfg(unix)] +mod unix { + use super::*; + use anyhow::{Context, anyhow}; + use std::fs::{self, File, OpenOptions}; + use std::io::{self, ErrorKind, Read, Seek, SeekFrom, Write}; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + use std::os::unix::io::AsRawFd; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::mpsc::{self, RecvTimeoutError}; + use std::time::{Duration, Instant}; + + const BLOCK: usize = 1024 * 1024; + + // Path bytes are length-delimited. Do not use lossily converted Unicode. + fn staging_paths(source: &Path, destination: &Path) -> Result<(PathBuf, PathBuf, PathBuf)> { + let canonical_source = fs::canonicalize(source) + .with_context(|| format!("resolve source path {}", source.display()))?; + let mut hasher = blake3::Hasher::new(); + hasher.update(b"MCH-resume-v1\0"); + for part in [canonical_source.as_os_str(), destination.as_os_str()] { + let bytes = part.as_bytes(); + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + let key = hasher.finalize().to_hex(); + let parent = destination.parent().context("destination has no parent")?; + let base = format!(".mch-resume-v1-{key}"); + Ok(( + parent.join(format!("{base}.part")), + parent.join(format!("{base}.lock")), + parent.join(format!("{base}.meta")), + )) + } + + fn private_file(path: &Path) -> Result { + let mut opt = OpenOptions::new(); + opt.read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + let file = match opt.create_new(true).open(path) { + Ok(file) => file, + Err(err) if err.kind() == ErrorKind::AlreadyExists => opt + .create_new(false) + .open(path) + .with_context(|| format!("open secure resume file {}", path.display()))?, + Err(err) => return Err(err).with_context(|| format!("create {}", path.display())), + }; + let meta = file.metadata()?; + if !meta.file_type().is_file() + || meta.nlink() != 1 + || meta.uid() != unsafe { libc::geteuid() } + || (meta.mode() & 0o077) != 0 + { + bail!( + "unsafe resume file: expected owned private regular inode with exactly one link: {}", + path.display() + ); + } + Ok(file) + } + + fn exclusive_lock(file: &File) -> Result<()> { + // Keep the inode and its lockfile permanently: unlinking a lockfile + // would allow a second process to create a fresh, unlocked inode. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == -1 { + return Err(io::Error::last_os_error()).context( + "another MCH --resume is active for this source/target (or locking unsupported)", + ); + } + Ok(()) + } + + fn inode_unchanged(file: &File, path: &Path) -> Result<()> { + let fd = file.metadata()?; + let name = fs::symlink_metadata(path) + .with_context(|| format!("resume file disappeared: {}", path.display()))?; + if !name.file_type().is_file() + || fd.dev() != name.dev() + || fd.ino() != name.ino() + || fd.nlink() != 1 + { + bail!( + "resume staging path changed or is no longer safe: {}", + path.display() + ); + } + Ok(()) + } + + // The manifest binds a partial copy to the original source inode, size and + // modification time. A matching prefix alone cannot detect a changed tail. + fn source_fingerprint(meta: &fs::Metadata) -> String { + format!( + "MCH-RESUME-1\n{}:{}:{}:{}:{}\n", + meta.dev(), + meta.ino(), + meta.len(), + meta.mtime(), + meta.mtime_nsec(), + ) + } + + fn validate_manifest(path: &Path, existed: bool, initial_source: &fs::Metadata) -> Result<()> { + let mut file = private_file(path)?; + let expected = source_fingerprint(initial_source); + if existed { + if file.metadata()?.len() > 256 { + bail!("resume source metadata manifest is oversized; refusing"); + } + let mut saved = String::new(); + file.read_to_string(&mut saved) + .context("read saved resume source identity")?; + if saved != expected { + bail!( + "resume source identity, size or modification time changed since partial copy; refusing" + ); + } + } else { + file.write_all(expected.as_bytes()) + .context("write persistent source identity for resume")?; + file.sync_all().context("sync resume source identity")?; + } + Ok(()) + } + + // Recheck both the opened source inode and its current pathname. + // This narrows the risk of deleting a newly replaced file on --move. + // An adversarial replacement between this check and unlink is still + // outside our race-safety guarantees. + fn require_original_source( + source: &Path, + handle: &File, + original: &fs::Metadata, + ) -> Result<()> { + let path = fs::symlink_metadata(source) + .with_context(|| format!("source no longer accessible: {}", source.display()))?; + let fd = handle.metadata().context("stat open original source")?; + for meta in [&path, &fd] { + if !meta.file_type().is_file() + || meta.dev() != original.dev() + || meta.ino() != original.ino() + || meta.len() != original.len() + || meta.mtime() != original.mtime() + || meta.mtime_nsec() != original.mtime_nsec() + { + bail!( + "resume source changed since transfer began; refusing source deletion: {}", + source.display() + ); + } + } + Ok(()) + } + + // Every staged byte is compared anew against CURRENT source content. + fn verify_prefix(source: &mut File, stage: &mut File, bytes: u64) -> Result<()> { + source.seek(SeekFrom::Start(0))?; + stage.seek(SeekFrom::Start(0))?; + let mut left = bytes; + let mut source_buf = vec![0_u8; BLOCK]; + let mut stage_buf = vec![0_u8; BLOCK]; + while left > 0 { + let n = left.min(BLOCK as u64) as usize; + source + .read_exact(&mut source_buf[..n]) + .context("read source during resume-prefix verification")?; + stage + .read_exact(&mut stage_buf[..n]) + .context("read staging during resume-prefix verification")?; + if source_buf[..n] != stage_buf[..n] { + bail!( + "resume prefix mismatch: partial copy differs from current source; refusing to overwrite" + ); + } + left -= n as u64; + } + Ok(()) + } + + fn show_transfer(copied: u64, reused: u64, total: u64, since: Instant, finished: bool) { + let copied_total = copied.saturating_add(reused); + let percent = if total == 0 { + 100.0 + } else { + (copied_total as f64 * 100.0 / total as f64).min(100.0) + }; + let seconds = since.elapsed().as_secs_f64().max(0.001); + let rate = copied as f64 / seconds; + let eta = if copied_total == total { + String::from("0s") + } else if rate > 0.0 { + format!("{}s", ((total - copied_total) as f64 / rate).ceil() as u64) + } else { + String::from("unknown") + }; + eprintln!( + "RESUME {}: {percent:.1}% | {reused} B existing verified | {copied} B newly copied | {total} B total | {:.1} MiB/s new-data rate | copy ETA {eta}", + if finished { "COPIED" } else { "COPYING" }, + rate / 1048576.0 + ); + } + + fn transfer( + cli: &Cli, + source: &mut File, + stage: &mut File, + reused: u64, + total: u64, + ) -> Result<()> { + let bytes_written = AtomicU64::new(0); + let began = Instant::now(); + std::thread::scope(|scope| -> Result<()> { + let (done, receiver) = mpsc::channel::<()>(); + let tracker = &bytes_written; + let reporter = if cli.show_progress() { + Some(scope.spawn(move || { + loop { + match receiver.recv_timeout(Duration::from_secs(1)) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => { + show_transfer( + tracker.load(Ordering::Relaxed), + reused, + total, + began, + true, + ); + break; + } + Err(RecvTimeoutError::Timeout) => { + show_transfer( + tracker.load(Ordering::Relaxed), + reused, + total, + began, + false, + ); + } + } + } + })) + } else { + None + }; + let result = (|| { + source.seek(SeekFrom::Start(reused))?; + stage.seek(SeekFrom::Start(reused))?; + let mut buffer = vec![0_u8; BLOCK]; + let mut remaining = total - reused; + while remaining != 0 { + let size = remaining.min(BLOCK as u64) as usize; + source + .read_exact(&mut buffer[..size]) + .context("read source while resuming")?; + stage + .write_all(&buffer[..size]) + .context("write persistent resume staging")?; + bytes_written.fetch_add(size as u64, Ordering::Relaxed); + remaining -= size as u64; + } + stage.flush().context("flush staged resume data")?; + stage.sync_all().context("sync staged resume data")?; + Ok(()) + })(); + let _ = done.send(()); + if let Some(reporter) = reporter { + reporter + .join() + .map_err(|_| anyhow!("resume progress thread panicked"))?; + } + result + }) + } + + pub(crate) fn copy( + cli: &Cli, + source: &Path, + destination: &Path, + source_times: Option<(FileTime, FileTime)>, + ) -> Result<()> { + let initial_meta = fs::symlink_metadata(source) + .with_context(|| format!("stat source {}", source.display()))?; + if !initial_meta.file_type().is_file() { + bail!("--resume supports only regular non-symlink source files"); + } + crate::engine::check_destination_file(source, destination, cli.overwrite)?; + let parent = destination.parent().context("destination has no parent")?; + // If resume is moving the only surviving original, reject a newly + // created destination tree whose ancestor directory entries have not + // yet been durably committed by the caller. + if cli.move_mode && !parent.is_dir() { + bail!( + "--resume --move requires an existing destination parent directory: {}", + parent.display() + ); + } + crate::engine::ensure_directory(parent)?; + let (partial_path, lock_path, manifest_path) = staging_paths(source, destination)?; + let lock = private_file(&lock_path)?; + exclusive_lock(&lock)?; + + let partial_exists = fs::symlink_metadata(&partial_path).is_ok(); + let manifest_exists = fs::symlink_metadata(&manifest_path).is_ok(); + if partial_exists != manifest_exists { + bail!( + "incomplete resume staging/manifest pair: {} and {}. Refusing automatic repair; inspect both files before manually removing stale state", + partial_path.display(), + manifest_path.display() + ); + } + let mut stage = private_file(&partial_path)?; + validate_manifest(&manifest_path, manifest_exists, &initial_meta)?; + inode_unchanged(&stage, &partial_path)?; + let reused = stage.metadata()?.len(); + if reused > initial_meta.len() { + bail!( + "resume staging is larger than source: {} > {} bytes", + reused, + initial_meta.len() + ); + } + let mut src = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(source) + .with_context(|| { + format!("open source without symlink following {}", source.display()) + })?; + let opened_meta = src.metadata()?; + if !opened_meta.is_file() + || opened_meta.dev() != initial_meta.dev() + || opened_meta.ino() != initial_meta.ino() + || opened_meta.len() != initial_meta.len() + { + bail!("source changed after resume planning; refusing"); + } + + verify_prefix(&mut src, &mut stage, reused)?; + if !cli.quiet { + println!("RESUME: {reused} existing bytes compared and verified against source"); + } + transfer(cli, &mut src, &mut stage, reused, initial_meta.len())?; + if stage.metadata()?.len() != initial_meta.len() { + bail!("staged file size changed during resume; refusing"); + } + // The source may have changed during the transfer. Hash independently, + // then ensure its metadata and inode still match the original handle. + let src_digest = crate::hashing::hash_file(source, cli.hash) + .context("hash entire source before publishing resumed copy")?; + let stage_digest = crate::hashing::hash_file(&partial_path, cli.hash) + .context("hash entire staged resumed copy")?; + if src_digest != stage_digest { + bail!("resume full-file {} verification FAILED", cli.hash.label()); + } + require_original_source(source, &src, &initial_meta) + .context("source changed while resuming; refusing to publish destination")?; + inode_unchanged(&stage, &partial_path)?; + if let Some((atime, mtime)) = source_times { + filetime::set_file_times(&partial_path, atime, mtime) + .context("apply source timestamps to verified resumed stage")?; + } + // NEVER broaden staging permissions before the publish rename. + // If a process crashes after chmod and before rename, reopening a + // 0644 stage would fail our strict owner-private resume checks. + stage.sync_all().context("sync private staged metadata")?; + crate::engine::check_destination_file(source, destination, cli.overwrite)?; + inode_unchanged(&stage, &partial_path)?; + crate::engine::atomic_replace(&partial_path, destination) + .context("publish fully verified resumed copy")?; + // The inode is now the FINAL destination, not a resumable .part. + // Set permissions via the already-open handle, avoiding a pathname + // race, then sync the final metadata before removing any source. + stage + .set_permissions(opened_meta.permissions()) + .context("apply source permissions to published resumed file")?; + stage + .sync_all() + .context("sync final resumed file permissions")?; + drop(stage); + if cli.move_mode { + // A synced *file* alone is not enough to durably record the rename. + // Never remove the only source until the destination directory's + // new entry is synced. This requires a filesystem that supports it. + fs::File::open(parent) + .with_context(|| { + format!("open destination directory for sync: {}", parent.display()) + })? + .sync_all() + .with_context(|| { + format!( + "sync resumed destination directory before source deletion: {}", + parent.display() + ) + })?; + require_original_source(source, &src, &initial_meta) + .context("source changed before resumed move cleanup")?; + } + fs::remove_file(&manifest_path) + .context("remove resume manifest after publishing verified file")?; + + cli.report( + &format!( + "RESUME {} VERIFIED {} -> {}", + cli.hash.label(), + source.display(), + destination.display() + ), + true, + ); + if cli.show_progress() { + eprintln!("RESUME HASH VERIFIED: {} complete", cli.hash.label()); + } + if cli.move_mode { + fs::remove_file(source) + .with_context(|| format!("remove verified moved source {}", source.display()))?; + cli.report(&format!("MOVED {}", source.display()), true); + } + if !cli.quiet { + println!( + "DONE: 1 resumed file{}", + if cli.move_mode { + " (source removed after verification)" + } else { + "" + } + ); + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn derived_staging_paths_depend_on_source_and_target() { + let t = tempfile::tempdir().unwrap(); + let a = t.path().join("a"); + let b = t.path().join("b"); + fs::write(&a, b"test").unwrap(); + fs::write(&b, b"test").unwrap(); + let (p1, l1, m1) = staging_paths(&a, &t.path().join("target")).unwrap(); + let (p2, _, _) = staging_paths(&b, &t.path().join("target")).unwrap(); + let (p3, _, _) = staging_paths(&a, &t.path().join("elsewhere")).unwrap(); + assert_ne!(p1, p2); + assert_ne!(p1, p3); + assert_ne!(p1, l1); + assert_ne!(p1, m1); + assert!(p1.file_name().unwrap().to_string_lossy().ends_with(".part")); + } + + #[test] + fn dangerous_symlink_staging_is_refused_without_modifying_target() { + let t = tempfile::tempdir().unwrap(); + let src = t.path().join("source"); + fs::write(&src, b"source").unwrap(); + let stage = t.path().join("stage"); + std::os::unix::fs::symlink(&src, &stage).unwrap(); + assert!(private_file(&stage).is_err()); + assert_eq!(fs::read(&src).unwrap(), b"source"); + } + + #[test] + fn replaced_source_file_is_rejected_even_with_same_size_and_mtime() { + let t = tempfile::tempdir().unwrap(); + let source = t.path().join("original.bin"); + let previous = t.path().join("saved-original.bin"); + fs::write(&source, b"OLD!").unwrap(); + let initial = fs::symlink_metadata(&source).unwrap(); + let open_original = File::open(&source).unwrap(); + fs::rename(&source, &previous).unwrap(); + fs::write(&source, b"NEW!").unwrap(); + filetime::set_file_times( + &source, + FileTime::from_last_access_time(&initial), + FileTime::from_last_modification_time(&initial), + ) + .unwrap(); + let error = require_original_source(&source, &open_original, &initial) + .expect_err("replaced original must never be deleted"); + assert!(error.to_string().contains("source changed"), "{error:#}"); + assert_eq!(fs::read(source).unwrap(), b"NEW!"); + assert_eq!(fs::read(previous).unwrap(), b"OLD!"); + } + + #[test] + fn prefix_corruption_is_rejected_without_modification() { + let t = tempfile::tempdir().unwrap(); + let a = t.path().join("source"); + let b = t.path().join("partial"); + fs::write(&a, b"abcdef").unwrap(); + fs::write(&b, b"abcDEF").unwrap(); + let mut src = File::open(&a).unwrap(); + let mut stage = File::open(&b).unwrap(); + assert!(verify_prefix(&mut src, &mut stage, 6).is_err()); + assert_eq!(fs::read(&b).unwrap(), b"abcDEF"); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 38883d0..f617ece 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -40,9 +40,17 @@ fn help_and_version_require_no_paths() { assert_success(mch(&["--help"])); let output = mch(&["-v"]); assert_success(output.clone()); - assert!(String::from_utf8_lossy(&output.stdout).contains("0.1.0")); + assert!(String::from_utf8_lossy(&output.stdout).contains(env!("CARGO_PKG_VERSION"))); assert_success(mch(&["--version"])); assert_error(mch(&[])); + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("source"); + let dst = tmp.path().join("destination"); + write(&src, "keep on accidental -v"); + let result = mch(&["-v", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(result.clone()); + assert!(String::from_utf8_lossy(&result.stderr).contains("no files were copied")); + assert!(src.exists() && !dst.exists()); } #[test] @@ -406,3 +414,893 @@ fn move_retains_source_when_destination_file_conflicts() { assert_eq!(fs::read_to_string(&src).unwrap(), "original"); assert_eq!(fs::read_to_string(&dst).unwrap(), "occupied"); } + +#[test] +fn progress_always_reports_verified_transfer_without_terminal_control() { + let t = TempDir::new().unwrap(); + let source = t.path().join("big.bin"); + let dest = t.path().join("copy.bin"); + fs::write(&source, vec![0x5Au8; 2 * 1024 * 1024]).unwrap(); + let out = mch(&[ + "--progress", + "always", + source.to_str().unwrap(), + dest.to_str().unwrap(), + ]); + assert_success(out.clone()); + assert_eq!(fs::read(source).unwrap(), fs::read(dest).unwrap()); + let progress = String::from_utf8_lossy(&out.stderr); + assert!( + progress.contains("PROGRESS COPY VERIFIED: 100.0%"), + "{progress}" + ); + assert!(progress.contains("2.0 MiB / 2.0 MiB copied"), "{progress}"); + assert!(progress.contains("1/1 files"), "{progress}"); + assert!(progress.contains("copy ETA 0s"), "{progress}"); + assert!( + !progress.contains('\r'), + "screenreader output must use normal lines" + ); + assert!(!progress.contains("\x1b["), "no ANSI control sequences"); +} + +#[test] +fn progress_auto_never_and_quiet_are_silent_in_piped_cli() { + let t = TempDir::new().unwrap(); + let src = t.path().join("input"); + write(&src, "test"); + for (flags, name) in [ + (vec![], "auto"), + (vec!["--progress", "never"], "never"), + (vec!["-q", "--progress", "always"], "quiet"), + (vec!["--progress", "always", "--dry-run"], "dry-run"), + ] { + let dst = t.path().join(name); + let mut args = flags; + args.extend([src.to_str().unwrap(), dst.to_str().unwrap()]); + let result = mch(&args); + assert_success(result.clone()); + assert!( + !String::from_utf8_lossy(&result.stderr).contains("PROGRESS"), + "unexpected progress in {name}" + ); + assert_eq!(dst.exists(), name != "dry-run"); + } +} + +#[test] +fn progress_always_with_empty_directory_and_zero_byte_file() { + let t = TempDir::new().unwrap(); + let empty = t.path().join("dir"); + fs::create_dir(&empty).unwrap(); + let out = mch(&[ + "--progress", + "always", + empty.to_str().unwrap(), + t.path().join("dest").to_str().unwrap(), + ]); + assert_success(out.clone()); + assert!(String::from_utf8_lossy(&out.stderr).contains("0/0 files")); + assert!(String::from_utf8_lossy(&out.stderr).contains("100.0%")); + let src = t.path().join("zero.bin"); + write(&src, ""); + let target = t.path().join("zero-copy.bin"); + let out = mch(&[ + "--progress", + "always", + src.to_str().unwrap(), + target.to_str().unwrap(), + ]); + assert_success(out.clone()); + assert!(String::from_utf8_lossy(&out.stderr).contains("1/1 files")); + assert!(String::from_utf8_lossy(&out.stderr).contains("0 B / 0 B copied")); +} + +#[test] +fn progress_parallel_move_matches_source_and_removes_it() { + let t = TempDir::new().unwrap(); + let source = t.path().join("folder"); + fs::create_dir(&source).unwrap(); + for i in 0..20 { + fs::write(source.join(format!("{i}.bin")), vec![i as u8; 4096]).unwrap(); + } + let target = t.path().join("dest"); + let out = mch(&[ + "--progress", + "always", + "--count", + "4", + "-m", + source.to_str().unwrap(), + target.to_str().unwrap(), + ]); + assert_success(out.clone()); + assert!(String::from_utf8_lossy(&out.stderr).contains("20/20 files")); + assert!(!source.exists()); + for i in 0..20 { + assert_eq!( + fs::read(target.join("folder").join(format!("{i}.bin"))).unwrap(), + vec![i as u8; 4096] + ); + } +} + +#[test] +fn progress_invalid_value_and_preflight_conflict_cannot_modify_source() { + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + let dest = t.path().join("dest"); + write(&src, "retain"); + write(&dest, "existing"); + assert_error(mch(&[ + "--progress", + "invalid", + src.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + let failure = mch(&[ + "--progress", + "always", + "--move", + src.to_str().unwrap(), + dest.to_str().unwrap(), + ]); + assert_error(failure.clone()); + assert!(!String::from_utf8_lossy(&failure.stderr).contains("COPY VERIFIED")); + assert_eq!(fs::read_to_string(&src).unwrap(), "retain"); + assert_eq!(fs::read_to_string(&dest).unwrap(), "existing"); +} + +#[cfg(unix)] +#[test] +fn progress_always_preserves_unix_file_mode() { + use std::os::unix::fs::PermissionsExt; + let t = TempDir::new().unwrap(); + let source = t.path().join("executable"); + let dest = t.path().join("copy"); + write(&source, "binary content"); + fs::set_permissions(&source, fs::Permissions::from_mode(0o751)).unwrap(); + assert_success(mch(&[ + "--progress", + "always", + source.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert_eq!( + fs::metadata(&dest).unwrap().permissions().mode() & 0o777, + 0o751 + ); + assert_eq!(fs::read(source).unwrap(), fs::read(dest).unwrap()); +} + +#[test] +fn preserve_times_for_file_with_and_without_progress() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("original.bin"); + write(&src, "mtime and atime test data"); + let access = FileTime::from_unix_time(1_600_000_000, 0); + let modified = FileTime::from_unix_time(1_640_000_000, 0); + set_file_times(&src, access, modified).unwrap(); + for (index, extra) in [Vec::<&str>::new(), vec!["--progress", "always"]] + .into_iter() + .enumerate() + { + let dst = t.path().join(format!("output-{index}.bin")); + let mut args = vec!["--preserve-times"]; + args.extend(extra); + args.extend([src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_success(mch(&args)); + let metadata = fs::metadata(&dst).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(&metadata).unix_seconds(), + modified.unix_seconds() + ); + assert_eq!( + FileTime::from_last_access_time(&metadata).unix_seconds(), + access.unix_seconds() + ); + assert_eq!(fs::read(&src).unwrap(), fs::read(&dst).unwrap()); + set_file_times(&src, access, modified).unwrap(); + } +} + +#[test] +fn preserve_times_of_new_nested_directories_after_copying_children() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("photos"); + fs::create_dir_all(src.join("nested/empty")).unwrap(); + write(&src.join("nested/picture"), "content"); + let access = FileTime::from_unix_time(1_600_000_000, 0); + let modified = FileTime::from_unix_time(1_640_000_000, 0); + for directory in [src.join("nested/empty"), src.join("nested"), src.clone()] { + set_file_times(directory, access, modified).unwrap(); + } + let out = t.path().join("archive"); + assert_success(mch(&[ + "--preserve-times", + "--count", + "4", + src.to_str().unwrap(), + out.to_str().unwrap(), + ])); + for directory in [ + out.join("photos"), + out.join("photos/nested"), + out.join("photos/nested/empty"), + ] { + let meta = fs::metadata(&directory).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(&meta).unix_seconds(), + modified.unix_seconds(), + "mtime of {}", + directory.display() + ); + assert_eq!( + FileTime::from_last_access_time(&meta).unix_seconds(), + access.unix_seconds(), + "atime of {}", + directory.display() + ); + } +} + +#[test] +fn preserve_times_does_not_apply_source_dir_times_to_existing_destination_dir() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("photos"); + let dest = t.path().join("archive"); + let already_there = dest.join("photos"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&already_there).unwrap(); + write(&src.join("new"), "new content"); + let old = FileTime::from_unix_time(1_600_000_000, 0); + let newer = FileTime::from_unix_time(1_700_000_000, 0); + set_file_times(&src, old, old).unwrap(); + set_file_times(&already_there, newer, newer).unwrap(); + assert_success(mch(&[ + "--preserve-times", + src.to_str().unwrap(), + dest.to_str().unwrap(), + ])); + assert!(already_there.join("new").exists()); + let result = FileTime::from_last_modification_time(&fs::metadata(&already_there).unwrap()); + assert_ne!( + result.unix_seconds(), + old.unix_seconds(), + "existing destination directories must not inherit source timestamps" + ); +} + +#[cfg(unix)] +#[test] +fn preserve_symlink_times_without_touching_link_target() { + use filetime::{FileTime, set_file_times, set_symlink_file_times}; + use std::os::unix::fs::symlink; + let t = TempDir::new().unwrap(); + let target = t.path().join("target"); + let link = t.path().join("link"); + let copied = t.path().join("copied-link"); + write(&target, "target bytes"); + symlink("target", &link).unwrap(); + let target_time = FileTime::from_unix_time(1_700_000_000, 0); + let link_time = FileTime::from_unix_time(1_600_000_000, 0); + set_file_times(&target, target_time, target_time).unwrap(); + set_symlink_file_times(&link, link_time, link_time).unwrap(); + assert_success(mch(&[ + "--preserve-times", + link.to_str().unwrap(), + copied.to_str().unwrap(), + ])); + assert_eq!(fs::read_link(&copied).unwrap(), Path::new("target")); + let copy_times = fs::symlink_metadata(&copied).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(©_times).unix_seconds(), + link_time.unix_seconds() + ); + let target_times = fs::metadata(&target).unwrap(); + assert_eq!( + FileTime::from_last_modification_time(&target_times).unix_seconds(), + target_time.unix_seconds(), + "link time preservation must not change the target timestamp" + ); +} + +#[test] +fn preserve_times_on_overwrite_and_move_and_dry_run() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + let dst = t.path().join("existing"); + write(&src, "replace me"); + write(&dst, "old"); + let mtime = FileTime::from_unix_time(1_600_000_000, 0); + set_file_times(&src, mtime, mtime).unwrap(); + assert_success(mch(&[ + "--dry-run", + "--preserve-times", + "--overwrite", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!(fs::read_to_string(&dst).unwrap(), "old"); + assert_success(mch(&[ + "--preserve-times", + "--overwrite", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!( + FileTime::from_last_modification_time(&fs::metadata(&dst).unwrap()).unix_seconds(), + mtime.unix_seconds() + ); + let moved = t.path().join("moved"); + assert_success(mch(&[ + "--move", + "--preserve-times", + src.to_str().unwrap(), + moved.to_str().unwrap(), + ])); + assert!(!src.exists()); + assert_eq!( + FileTime::from_last_modification_time(&fs::metadata(&moved).unwrap()).unix_seconds(), + mtime.unix_seconds() + ); +} + +#[cfg(unix)] +#[test] +fn preserve_original_atime_for_several_hardlink_sources() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let input = t.path().join("hardlinks"); + let out = t.path().join("out"); + fs::create_dir_all(&input).unwrap(); + let first = input.join("a"); + let second = input.join("b"); + write(&first, "same inode must keep pre-copy access time"); + // Some Android/Termux installations deny hardlink creation even inside + // their own temporary directory. Keep the full test mandatory on Unix + // CI (Linux/macOS), but skip this unsupported fixture on Android only. + if let Err(error) = fs::hard_link(&first, &second) { + #[cfg(target_os = "android")] + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported + ) { + eprintln!("Android filesystem does not permit hardlinks: {error}"); + return; + } + panic!("create hardlink test fixture: {error}"); + } + // atime older than mtime triggers relatime updates on Linux. + let old_access = FileTime::from_unix_time(1_600_000_000, 0); + let newer_mtime = FileTime::from_unix_time(1_700_000_000, 0); + set_file_times(&first, old_access, newer_mtime).unwrap(); + assert_success(mch(&[ + "--preserve-times", + "--count", + "1", + input.to_str().unwrap(), + out.to_str().unwrap(), + ])); + for output in [out.join("hardlinks/a"), out.join("hardlinks/b")] { + let meta = fs::metadata(&output).unwrap(); + assert_eq!( + FileTime::from_last_access_time(&meta).unix_seconds(), + old_access.unix_seconds(), + "must preserve the original pre-copy atime for {}", + output.display() + ); + assert_eq!( + FileTime::from_last_modification_time(&meta).unix_seconds(), + newer_mtime.unix_seconds() + ); + } +} + +#[test] +fn progress_without_hash_must_never_claim_verification() { + let t = TempDir::new().unwrap(); + let src = t.path().join("data"); + let dst = t.path().join("copy"); + write(&src, "no-hash intentionally bypasses verification"); + let output = mch(&[ + "--progress", + "always", + "-N", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert_success(output.clone()); + let status = String::from_utf8_lossy(&output.stderr); + assert!( + status.contains("PROGRESS COPY COMPLETE (NO HASH VERIFICATION): 100.0%"), + "{status}" + ); + assert!(!status.contains("COPY VERIFIED"), "{status}"); + assert_eq!(fs::read(&src).unwrap(), fs::read(&dst).unwrap()); +} + +#[cfg(unix)] +fn resume_fixture_paths( + source: &Path, + dest: &Path, +) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { + use std::os::unix::ffi::OsStrExt; + let resolved = fs::canonicalize(source).unwrap(); + // macOS TempDir names commonly start with /var, which is a symlink + // into /private/var. Engine planning normalizes the destination parent, + // so fixtures must hash that *same physical destination*. + let resolved_destination = fs::canonicalize(dest.parent().unwrap()) + .unwrap() + .join(dest.file_name().unwrap()); + let mut hash = blake3::Hasher::new(); + hash.update(b"MCH-resume-v1\0"); + for part in [resolved.as_os_str(), resolved_destination.as_os_str()] { + let bytes = part.as_bytes(); + hash.update(&(bytes.len() as u64).to_le_bytes()); + hash.update(bytes); + } + let base = format!(".mch-resume-v1-{}", hash.finalize().to_hex()); + ( + dest.parent().unwrap().join(format!("{base}.part")), + dest.parent().unwrap().join(format!("{base}.lock")), + dest.parent().unwrap().join(format!("{base}.meta")), + ) +} + +#[cfg(unix)] +fn seed_resume_partial(source: &Path, dest: &Path, bytes: usize) -> std::path::PathBuf { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let (part, _, manifest) = resume_fixture_paths(source, dest); + let meta = fs::metadata(source).unwrap(); + let body = fs::read(source).unwrap(); + assert!(bytes <= body.len()); + fs::write(&part, &body[..bytes]).unwrap(); + fs::set_permissions(&part, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write( + &manifest, + format!( + "MCH-RESUME-1\n{}:{}:{}:{}:{}\n", + meta.dev(), + meta.ino(), + meta.len(), + meta.mtime(), + meta.mtime_nsec() + ), + ) + .unwrap(); + fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600)).unwrap(); + part +} + +#[cfg(unix)] +#[test] +fn resume_fresh_and_empty_files_are_verified() { + let t = TempDir::new().unwrap(); + for length in [0, 20, 2 * 1024 * 1024 + 19] { + let src = t.path().join(format!("source-{length}")); + let dst = t.path().join(format!("target-{length}")); + fs::write(&src, vec![0xA5; length]).unwrap(); + let out = mch(&[ + "--resume", + "--progress", + "always", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert_success(out.clone()); + assert_eq!(fs::read(&src).unwrap(), fs::read(&dst).unwrap()); + let status = String::from_utf8_lossy(&out.stderr); + assert!(status.contains("RESUME HASH VERIFIED"), "{status}"); + assert!(status.contains("RESUME COPIED"), "{status}"); + let (partial, _, manifest) = resume_fixture_paths(&src, &dst); + assert!(!partial.exists()); + assert!(!manifest.exists()); + } +} + +#[cfg(unix)] +#[test] +fn resume_valid_prefix_and_persists_source_file_times() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("somé input file"); + let dst = t.path().join("output file"); + let data = vec![0x5F; 3 * 1024 * 1024 + 37]; + fs::write(&src, &data).unwrap(); + let atime = FileTime::from_unix_time(1_600_000_000, 0); + let mtime = FileTime::from_unix_time(1_650_000_000, 0); + set_file_times(&src, atime, mtime).unwrap(); + let partial = seed_resume_partial(&src, &dst, 2 * 1024 * 1024); + let out = mch(&[ + "--resume", + "--preserve-times", + "--progress", + "always", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert_success(out.clone()); + let log = String::from_utf8_lossy(&out.stdout); + assert!(log.contains("2097152 existing bytes"), "{log}"); + let progress = String::from_utf8_lossy(&out.stderr); + assert!( + progress.contains("2097152 B existing verified"), + "{progress}" + ); + assert!(progress.contains("1048613 B newly copied"), "{progress}"); + assert_eq!(fs::read(&dst).unwrap(), data); + assert!(!partial.exists()); + assert_eq!( + FileTime::from_last_modification_time(&fs::metadata(&dst).unwrap()).unix_seconds(), + mtime.unix_seconds() + ); +} + +#[cfg(unix)] +#[test] +fn resume_corrupted_prefix_and_oversized_partial_fail_without_data_loss() { + use std::os::unix::fs::PermissionsExt; + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + fs::write(&src, b"this is the immutable original content").unwrap(); + let partial = seed_resume_partial(&src, &dst, 20); + fs::write(&partial, b"CHANGED CONTENT HERE").unwrap(); + fs::set_permissions(&partial, fs::Permissions::from_mode(0o600)).unwrap(); + let before = fs::read(&partial).unwrap(); + assert_error(mch(&[ + "--resume", + "--move", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!( + fs::read(&src).unwrap(), + b"this is the immutable original content" + ); + assert_eq!(fs::read(&partial).unwrap(), before); + assert!(!dst.exists()); + fs::write(&partial, vec![1; 512]).unwrap(); + fs::set_permissions(&partial, fs::Permissions::from_mode(0o600)).unwrap(); + assert_error(mch(&[ + "--resume", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!( + fs::read(&src).unwrap(), + b"this is the immutable original content" + ); + assert!(!dst.exists()); +} + +#[cfg(unix)] +#[test] +fn resume_source_changed_after_partial_is_rejected() { + use filetime::{FileTime, set_file_times}; + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + fs::write(&src, b"same initial prefix, original tail content").unwrap(); + let partial = seed_resume_partial(&src, &dst, 11); + // Change only the tail while keeping the source size and prefix intact. + let mut modified = fs::read(&src).unwrap(); + let last = modified.len() - 1; + modified[last] ^= 1; + fs::write(&src, modified).unwrap(); + set_file_times( + &src, + FileTime::from_unix_time(1_600_000_000, 0), + FileTime::from_unix_time(1_700_000_000, 0), + ) + .unwrap(); + let outcome = mch(&["--resume", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(outcome.clone()); + assert!( + String::from_utf8_lossy(&outcome.stderr).contains("identity, size or modification time") + ); + assert!(partial.exists()); + assert!(!dst.exists()); +} + +#[cfg(unix)] +#[test] +fn resume_rejects_symlink_staging_hardlinks_and_malformed_manifest() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + fs::write(&src, b"still present").unwrap(); + let part = seed_resume_partial(&src, &dst, 3); + let (part_path, _, manifest) = resume_fixture_paths(&src, &dst); + assert_eq!(part, part_path); + fs::remove_file(&part).unwrap(); + symlink(&src, &part).unwrap(); + assert_error(mch(&[ + "--resume", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_eq!(fs::read(&src).unwrap(), b"still present"); + fs::remove_file(&part).unwrap(); + fs::write(&part, b"sti").unwrap(); + fs::set_permissions(&part, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(&manifest, b"invalid-state").unwrap(); + assert_error(mch(&[ + "--resume", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert!(!dst.exists()); + assert_eq!(fs::read(&src).unwrap(), b"still present"); +} + +#[cfg(unix)] +#[test] +fn resume_existing_destination_needs_overwrite_and_move_removes_only_after_hash() { + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + fs::write(&src, b"verified copied content").unwrap(); + fs::write(&dst, b"preserve existing content").unwrap(); + let out = mch(&["--resume", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(out); + assert_eq!(fs::read(&dst).unwrap(), b"preserve existing content"); + assert!(src.exists()); + let stage = seed_resume_partial(&src, &dst, 14); + assert_success(mch(&[ + "--resume", + "--move", + "--overwrite", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert!(!src.exists()); + assert!(!stage.exists()); + assert_eq!(fs::read(&dst).unwrap(), b"verified copied content"); +} + +#[test] +fn resume_rejects_unsupported_flags_and_dry_run_does_not_write() { + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + write(&src, "contents"); + assert_error(mch(&[ + "--resume", + "-N", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_error(mch(&[ + "--resume", + "--count", + "2", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_error(mch(&[ + "--resume", + "-O", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert_error(mch(&[ + "--resume", + src.to_str().unwrap(), + src.to_str().unwrap(), + ])); + #[cfg(unix)] + { + assert_success(mch(&[ + "--resume", + "--dry-run", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ])); + assert!(!dst.exists()); + let (stage, lock, manifest) = resume_fixture_paths(&src, &dst); + assert!(!stage.exists() && !lock.exists() && !manifest.exists()); + } + assert_eq!(fs::read(&src).unwrap(), b"contents"); +} + +#[cfg(unix)] +#[test] +fn resume_rejects_locked_transfer_and_does_not_publish() { + use std::fs::OpenOptions; + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("destination"); + fs::write(&src, b"concurrency conflict").unwrap(); + let (_, lock_path, _) = resume_fixture_paths(&src, &dst); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .mode(0o600) + .open(&lock_path) + .unwrap(); + assert_eq!( + unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + let out = mch(&["--resume", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(out.clone()); + assert!( + String::from_utf8_lossy(&out.stderr).contains("another MCH --resume is active"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(!dst.exists()); + assert_eq!(fs::read(&src).unwrap(), b"concurrency conflict"); +} + +#[cfg(unix)] +#[test] +fn resume_rejects_missing_manifest_and_lockfile_symlink() { + use std::os::unix::fs::symlink; + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + let dst = t.path().join("dst"); + fs::write(&src, b"source").unwrap(); + let (part, lock, manifest) = resume_fixture_paths(&src, &dst); + fs::write(&part, b"source").unwrap(); + // An abandoned/incomplete pair must NOT be automatically trusted. + let out = mch(&["--resume", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(out.clone()); + let diagnostic = String::from_utf8_lossy(&out.stderr); + assert!( + diagnostic.contains("incomplete resume staging/manifest"), + "{diagnostic}" + ); + assert!( + diagnostic.contains(&part.display().to_string()), + "{diagnostic}" + ); + assert!( + diagnostic.contains(&manifest.display().to_string()), + "{diagnostic}" + ); + assert!(!manifest.exists()); + fs::remove_file(&lock).unwrap(); + symlink(&src, &lock).unwrap(); + let out = mch(&["--resume", src.to_str().unwrap(), dst.to_str().unwrap()]); + assert_error(out); + assert_eq!(fs::read(&src).unwrap(), b"source"); + assert!(!dst.exists()); +} + +#[cfg(unix)] +#[test] +fn resume_rejects_hardlinked_partial_instead_of_modifying_linked_source() { + use std::os::unix::fs::MetadataExt; + let t = TempDir::new().unwrap(); + let src = t.path().join("source"); + let dst = t.path().join("dst"); + fs::write(&src, b"important source content").unwrap(); + let stage = seed_resume_partial(&src, &dst, 5); + fs::remove_file(&stage).unwrap(); + // Android may block creating hardlinks at the OS level. + if let Err(err) = fs::hard_link(&src, &stage) { + #[cfg(target_os = "android")] + if matches!( + err.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::Unsupported + ) { + return; + } + panic!("unexpected hardlink fixture error: {err}"); + } + assert!(fs::metadata(&stage).unwrap().nlink() >= 2); + let result = mch(&[ + "--resume", + "--move", + src.to_str().unwrap(), + dst.to_str().unwrap(), + ]); + assert_error(result); + assert_eq!(fs::read(&src).unwrap(), b"important source content"); + assert!(!dst.exists()); +} + +#[cfg(unix)] +#[test] +fn resume_refuses_directory_and_symlink_source_even_on_dry_run() { + use std::os::unix::fs::symlink; + let t = TempDir::new().unwrap(); + let src = t.path().join("src"); + let dir = t.path().join("dir"); + let link = t.path().join("link"); + let out = t.path().join("out"); + fs::write(&src, b"keep").unwrap(); + fs::create_dir(&dir).unwrap(); + symlink(&src, &link).unwrap(); + assert_error(mch(&[ + "--resume", + "--dry-run", + dir.to_str().unwrap(), + out.to_str().unwrap(), + ])); + assert_error(mch(&[ + "--resume", + link.to_str().unwrap(), + out.to_str().unwrap(), + ])); + assert_eq!(fs::read(&src).unwrap(), b"keep"); + assert!(!out.exists()); +} + +#[test] +fn move_empty_tree_with_new_parent_directories_succeeds() { + let t = TempDir::new().unwrap(); + let source = t.path().join("empty-src"); + fs::create_dir_all(source.join("sub/empty")).unwrap(); + let dest = t.path().join("not-yet-created/level/toplevel"); + let result = mch(&["--move", source.to_str().unwrap(), dest.to_str().unwrap()]); + assert_success(result); + assert!(!source.exists()); + assert!(dest.join("empty-src/sub/empty").is_dir()); +} + +#[cfg(unix)] +#[test] +fn resume_move_rejects_new_destination_parent_and_keeps_source() { + let t = TempDir::new().unwrap(); + let source = t.path().join("only-source"); + let parent = t.path().join("missing/directory"); + let destination = parent.join("copy"); + fs::write(&source, b"never remove this without durable destination").unwrap(); + let output = mch(&[ + "--resume", + "--move", + source.to_str().unwrap(), + destination.to_str().unwrap(), + ]); + assert_error(output.clone()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("--resume --move requires an existing destination parent"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + fs::read(source).unwrap(), + b"never remove this without durable destination" + ); + assert!(!parent.exists()); +} + +#[cfg(unix)] +#[test] +fn resume_publishes_original_unix_permissions_after_private_staging() { + use std::os::unix::fs::PermissionsExt; + let t = TempDir::new().unwrap(); + let source = t.path().join("source"); + let destination = t.path().join("destination"); + fs::write(&source, b"permission preservation with resumed copy").unwrap(); + fs::set_permissions(&source, fs::Permissions::from_mode(0o644)).unwrap(); + let partial = seed_resume_partial(&source, &destination, 10); + assert_eq!( + fs::metadata(&partial).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_success(mch(&[ + "--resume", + source.to_str().unwrap(), + destination.to_str().unwrap(), + ])); + assert_eq!( + fs::metadata(&destination).unwrap().permissions().mode() & 0o777, + 0o644 + ); + assert_eq!(fs::read(&source).unwrap(), fs::read(&destination).unwrap()); + assert!(!partial.exists()); +}